1use std::path::PathBuf;
10
11use anyhow::{bail, Context, Result};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use super::Rule;
16
17pub const PREFACE_BUDGET: usize = 1200;
19
20#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22pub struct People {
23 #[serde(default, skip_serializing_if = "Vec::is_empty")]
25 pub handles: Vec<String>,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
28 pub names: Vec<String>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct UserRule {
36 pub id: String,
37 pub text: String,
38 pub author: String,
39 pub reason: String,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub pattern: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub verdict: Option<String>,
44 #[serde(default, skip_serializing_if = "people_empty")]
45 pub deny_mention: People,
46 #[serde(default, skip_serializing_if = "people_empty")]
47 pub deny_reply_to: People,
48 pub written_at: String,
49 #[serde(default, skip_serializing_if = "String::is_empty")]
52 pub updated_at: String,
53 #[serde(default, skip_serializing_if = "is_false")]
56 pub deleted: bool,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub from: Option<String>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub due_at: Option<String>,
64}
65
66fn people_empty(p: &People) -> bool {
67 p.handles.is_empty() && p.names.is_empty()
68}
69
70fn is_false(value: &bool) -> bool {
71 !*value
72}
73
74#[derive(Debug, Default, Serialize, Deserialize)]
75struct File {
76 #[serde(default)]
77 rules: Vec<UserRule>,
78}
79
80#[must_use]
82pub fn rules_path() -> Option<PathBuf> {
83 Some(super::config_base()?.join("ljos").join("user-rules.json"))
84}
85
86pub fn load() -> Result<Vec<UserRule>> {
93 Ok(read_all()?
94 .into_iter()
95 .filter(|rule| !rule.deleted)
96 .collect())
97}
98
99pub(crate) fn read_all() -> Result<Vec<UserRule>> {
105 let Some(path) = rules_path() else {
106 return Ok(Vec::new());
107 };
108 if !path.exists() {
109 return Ok(Vec::new());
110 }
111 let text = std::fs::read_to_string(&path)
112 .with_context(|| format!("user rules: read {} failed", path.display()))?;
113 parse_rules(&text)
114 .with_context(|| format!("user rules: {} is not a user-rules file", path.display()))
115}
116
117pub(crate) fn parse_rules(text: &str) -> Result<Vec<UserRule>> {
123 if text.trim().is_empty() {
124 return Ok(Vec::new());
125 }
126 let file: File = serde_json::from_str(text)?;
127 Ok(file.rules)
128}
129
130pub(crate) fn render_rules(rules: &[UserRule]) -> Result<String> {
136 let rules: Vec<UserRule> = rules.iter().cloned().map(dated).collect();
137 let body = serde_json::to_string_pretty(&File { rules })?;
138 Ok(format!("{body}\n"))
139}
140
141fn store(rules: &[UserRule]) -> Result<()> {
142 let path = rules_path().context("user rules: no home and no XDG_CONFIG_HOME")?;
143 if let Some(parent) = path.parent() {
144 std::fs::create_dir_all(parent)
145 .with_context(|| format!("user rules: create {} failed", parent.display()))?;
146 }
147 let body = render_rules(rules)?;
148 let tmp = path.with_extension("json.tmp");
149 std::fs::write(&tmp, &body)
150 .with_context(|| format!("user rules: write {} failed", tmp.display()))?;
151 std::fs::rename(&tmp, &path)
152 .with_context(|| format!("user rules: rename into {} failed", path.display()))?;
153 Ok(())
154}
155
156pub(crate) fn replace_stored(rules: &[UserRule]) -> Result<()> {
158 store(rules)
159}
160
161fn append(rule: UserRule) -> Result<UserRule> {
162 let mut rules = read_all()?;
163 rules.push(rule.clone());
164 store(&rules)?;
165 sync_after_write();
166 Ok(rule)
167}
168
169#[must_use]
171pub fn is_due(rule: &UserRule, now: &str) -> bool {
172 rule.due_at
173 .as_deref()
174 .is_some_and(|due| !due.is_empty() && due <= now)
175}
176
177#[must_use]
179pub fn review_due(now: &str) -> usize {
180 load()
181 .unwrap_or_default()
182 .iter()
183 .filter(|rule| is_due(rule, now))
184 .count()
185}
186
187pub fn sweep(now: &str) -> Result<usize> {
194 let rules = read_all()?;
195 let kept: Vec<UserRule> = rules
196 .iter()
197 .filter(|rule| !is_due(rule, now))
198 .cloned()
199 .collect();
200 let dropped = rules.len() - kept.len();
201 if dropped > 0 {
202 store(&kept)?;
203 }
204 Ok(dropped)
205}
206
207fn new_id(text: &str) -> String {
208 let mut raw = [0u8; 16];
209 if let Ok(mut file) = std::fs::File::open("/dev/urandom") {
210 use std::io::Read;
211 let _ = file.read_exact(&mut raw);
212 }
213 let tick = std::time::SystemTime::now()
214 .duration_since(std::time::UNIX_EPOCH)
215 .map(|d| d.as_nanos() as u64)
216 .unwrap_or(0);
217 for (i, byte) in raw.iter_mut().enumerate().take(8) {
218 *byte ^= (tick >> (i * 8)) as u8;
219 }
220 for (i, byte) in text.bytes().enumerate() {
221 raw[i % raw.len()] ^= byte.wrapping_add(i as u8);
222 }
223 let mut hex = String::with_capacity(32);
224 for byte in raw {
225 hex.push(b"0123456789abcdef"[(byte >> 4) as usize] as char);
226 hex.push(b"0123456789abcdef"[(byte & 0x0f) as usize] as char);
227 }
228 format!("ur-{hex}")
229}
230
231fn stamp(rule: &UserRule) -> &str {
232 let at = rule.updated_at.trim();
233 if at.is_empty() {
234 rule.written_at.trim()
235 } else {
236 at
237 }
238}
239
240fn dated(mut rule: UserRule) -> UserRule {
241 if rule.updated_at.trim().is_empty() {
242 rule.updated_at = rule.written_at.clone();
243 }
244 rule
245}
246
247#[must_use]
251pub fn merge_rows(rows: impl IntoIterator<Item = UserRule>) -> Vec<UserRule> {
252 let mut best: std::collections::BTreeMap<String, UserRule> = std::collections::BTreeMap::new();
253 for rule in rows {
254 let id = rule.id.trim().to_string();
255 if id.is_empty() {
256 continue;
257 }
258 let rule = dated(rule);
259 if best.get(&id).is_some_and(|have| beats(have, &rule)) {
260 continue;
261 }
262 best.insert(id, rule);
263 }
264 best.into_values().collect()
265}
266
267fn beats(have: &UserRule, incoming: &UserRule) -> bool {
268 match stamp(have).cmp(stamp(incoming)) {
269 std::cmp::Ordering::Greater => true,
270 std::cmp::Ordering::Less => false,
271 std::cmp::Ordering::Equal => have.deleted && !incoming.deleted,
272 }
273}
274
275fn normalize_handle(raw: &str) -> Result<String> {
276 let handle = raw.trim().trim_start_matches('@');
277 if handle.is_empty()
278 || !handle
279 .chars()
280 .all(|c| c.is_ascii_alphanumeric() || c == '-')
281 {
282 bail!("prefer: {raw:?} is not a handle");
283 }
284 Ok(handle.to_ascii_lowercase())
285}
286
287fn clean_people(people: &People) -> Result<People> {
288 let mut handles = Vec::new();
289 for handle in &people.handles {
290 let handle = normalize_handle(handle)?;
291 if !handles.contains(&handle) {
292 handles.push(handle);
293 }
294 }
295 let mut names = Vec::new();
296 for name in &people.names {
297 let name = name.split_whitespace().collect::<Vec<_>>().join(" ");
298 if name.is_empty() {
299 bail!("prefer: a name needs letters");
300 }
301 if !names
302 .iter()
303 .any(|kept: &String| kept.eq_ignore_ascii_case(&name))
304 {
305 names.push(name);
306 }
307 }
308 Ok(People { handles, names })
309}
310
311fn fresh(text: &str, reason: &str, from: Option<String>) -> Result<UserRule> {
312 let text = text.trim();
313 let reason = reason.trim();
314 if text.is_empty() {
315 bail!("prefer: a user rule needs the words it states");
316 }
317 if reason.is_empty() {
318 bail!("prefer: a user rule needs a reason, so say why with --why");
319 }
320 let now = super::now_utc();
321 Ok(UserRule {
322 id: new_id(text),
323 text: text.to_string(),
324 author: super::holder_name(),
325 reason: reason.to_string(),
326 pattern: None,
327 verdict: None,
328 deny_mention: People::default(),
329 deny_reply_to: People::default(),
330 written_at: now.clone(),
331 updated_at: now,
332 deleted: false,
333 from,
334 due_at: None,
335 })
336}
337
338pub fn write_preference(
345 text: &str,
346 reason: &str,
347 deny_mention: &People,
348 deny_reply_to: &People,
349) -> Result<UserRule> {
350 let mut rule = fresh(text, reason, None)?;
351 rule.deny_mention = clean_people(deny_mention)?;
352 rule.deny_reply_to = clean_people(deny_reply_to)?;
353 append(rule)
354}
355
356pub fn write_argv(rule: &Rule) -> Result<UserRule> {
362 super::validate_rule(rule)?;
363 let mut row = fresh(rule.reason.trim(), rule.reason.trim(), None)?;
364 row.pattern = Some(rule.pattern.trim().to_string());
365 row.verdict = Some(rule.verdict.clone());
366 append(row)
367}
368
369pub fn promote_atom(
376 atom: &Value,
377 reason: &str,
378 deny_mention: &People,
379 deny_reply_to: &People,
380) -> Result<UserRule> {
381 let kind = atom.get("kind").and_then(Value::as_str).unwrap_or("");
382 if kind != "lesson" {
383 let id = atom.get("id").and_then(Value::as_str).unwrap_or("(no id)");
384 bail!("prefer: {id} is a {kind}, and only a lesson is promoted");
385 }
386 let id = atom
387 .get("id")
388 .and_then(Value::as_str)
389 .context("prefer: the lesson has no id")?
390 .to_string();
391 let text = atom
392 .get("text")
393 .and_then(Value::as_str)
394 .unwrap_or("")
395 .trim();
396 let mut rule = fresh(text, reason, Some(id))?;
397 rule.deny_mention = clean_people(deny_mention)?;
398 rule.deny_reply_to = clean_people(deny_reply_to)?;
399 append(rule)
400}
401
402pub fn forget(id: &str) -> Result<UserRule> {
410 let id = id.trim();
411 if id.is_empty() {
412 bail!("prefer: a user rule id is required");
413 }
414 let mut rules = read_all()?;
415 let Some(rule) = rules.iter_mut().find(|rule| rule.id == id) else {
416 bail!("prefer: no user rule {id}");
417 };
418 if !rule.deleted {
419 rule.deleted = true;
420 rule.updated_at = super::now_utc();
421 let written = rule.clone();
422 store(&rules)?;
423 sync_after_write();
424 return Ok(written);
425 }
426 Ok(rule.clone())
427}
428
429pub fn promote_lesson(
436 id: &str,
437 reason: &str,
438 deny_mention: &People,
439 deny_reply_to: &People,
440) -> Result<UserRule> {
441 let id = id.trim();
442 if id.is_empty() {
443 bail!("prefer: a lesson id is required");
444 }
445 let client = super::pack()?;
446 let atoms =
447 super::atoms_lean(&client, &client.workspace()).context("prefer: GET /v1/atoms failed")?;
448 let atom = atoms
449 .into_iter()
450 .find(|atom| atom.get("id").and_then(Value::as_str) == Some(id))
451 .with_context(|| format!("prefer: no atom {id} in this seat's pack"))?;
452 promote_atom(&atom, reason, deny_mention, deny_reply_to)
453}
454
455fn sync_budget_ms(default: u64) -> u64 {
456 std::env::var("LJOS_USER_RULES_SYNC_MS")
457 .ok()
458 .and_then(|value| value.trim().parse().ok())
459 .filter(|ms: &u64| *ms > 0)
460 .unwrap_or(default)
461}
462
463fn sync_after_write() {
464 let budget = std::time::Duration::from_millis(sync_budget_ms(4_000));
465 let host = super::sync::host();
466 for root in super::sync::user_rule_trackers() {
467 let _ = super::sync::exchange_user_rules(&root, &host, budget);
468 }
469}
470
471pub fn sync_for_hook() {
474 let budget = std::time::Duration::from_millis(sync_budget_ms(1_500));
475 let host = super::sync::host();
476 for root in super::sync::user_rule_trackers() {
477 let _ = super::sync::exchange_user_rules(&root, &host, budget);
478 }
479}
480
481pub fn sync_now() -> Result<String> {
488 if std::env::var("LJOS_USER_RULES_SYNC").ok().as_deref() == Some("off") {
489 bail!("prefer: user-rule sync is off");
490 }
491 let roots = super::sync::user_rule_trackers();
492 if roots.is_empty() {
493 bail!(
494 "prefer: no tracker repository to sync user rules through. Set VISSUE_ROOT, or repos in ~/.config/ljos/sync.toml"
495 );
496 }
497 let budget = std::time::Duration::from_millis(sync_budget_ms(8_000));
498 let host = super::sync::host();
499 let mut out = String::new();
500 for root in roots {
501 out.push_str(&super::sync::exchange_user_rules(&root, &host, budget)?);
502 }
503 if out.is_empty() {
504 out.push_str("sync: user rules: none\n");
505 }
506 Ok(out)
507}
508
509#[must_use]
511pub fn ack(rule: &UserRule) -> String {
512 if rule.deleted {
513 format!("user rule {}\tremoved\t{}", rule.id, rule.text)
514 } else {
515 format!("user rule {}\t{}", rule.id, rule.text)
516 }
517}
518
519#[must_use]
521pub fn to_value(rule: &UserRule) -> Value {
522 serde_json::to_value(rule).unwrap_or(Value::Null)
523}
524
525#[must_use]
527pub fn format_list() -> String {
528 let Ok(rules) = load() else {
529 return String::new();
530 };
531 if rules.is_empty() {
532 return String::new();
533 }
534 let mut out = String::from("user rules (shared, never reviewed):\n");
535 for rule in &rules {
536 let law = match (&rule.verdict, &rule.pattern) {
537 (Some(verdict), Some(pattern)) => format!("{verdict}\t{pattern}\t"),
538 _ => String::new(),
539 };
540 out.push_str(&format!(
541 "user\t{law}{}\t{}\t{}\n",
542 rule.text, rule.author, rule.reason
543 ));
544 }
545 out
546}
547
548fn one_line(text: &str) -> String {
549 text.split_whitespace().collect::<Vec<_>>().join(" ")
550}
551
552fn omission(left: usize) -> String {
553 if left == 0 {
554 String::new()
555 } else {
556 format!("{left} user rules omitted.\n")
557 }
558}
559
560#[must_use]
562pub fn preface() -> String {
563 render(&load().unwrap_or_default(), PREFACE_BUDGET)
564}
565
566fn render(rules: &[UserRule], budget: usize) -> String {
567 if rules.is_empty() || budget == 0 {
568 return String::new();
569 }
570 let header = "User rules (standing, shared by every seat on this machine):\n";
571 let mut body = String::new();
572 let mut shown = 0usize;
573 for rule in rules {
574 let line = format!("- {}\n", one_line(&rule.text));
575 let note = omission(rules.len() - (shown + 1));
576 let used = header.chars().count()
577 + body.chars().count()
578 + line.chars().count()
579 + note.chars().count();
580 if used <= budget {
581 body.push_str(&line);
582 shown += 1;
583 continue;
584 }
585 if shown == 0 {
586 let note = omission(rules.len().saturating_sub(1));
587 let room = budget.saturating_sub(header.chars().count() + note.chars().count());
588 let take = room.saturating_sub(6);
589 if take > 0 {
590 let cut: String = one_line(&rule.text).chars().take(take).collect();
591 body.push_str(&format!("- {cut}...\n"));
592 shown = 1;
593 }
594 }
595 break;
596 }
597 if shown == 0 {
598 return String::new();
599 }
600 let mut out = format!("{header}{body}");
601 let left = rules.len() - shown;
602 if left > 0 {
603 out.push_str(&omission(left));
604 }
605 if out.chars().count() > budget {
606 out = out.chars().take(budget).collect();
607 }
608 out.trim_end().to_string()
609}
610
611#[must_use]
614pub fn prepend(body: String) -> String {
615 let head = preface();
616 if head.is_empty() {
617 body
618 } else if body.is_empty() {
619 head
620 } else {
621 format!("{head}\n{body}")
622 }
623}
624
625#[must_use]
628pub fn without_preface(text: &str) -> String {
629 let head = preface();
630 if head.is_empty() {
631 return text.to_string();
632 }
633 text.trim_start()
634 .strip_prefix(&head)
635 .map(|rest| rest.trim_start().to_string())
636 .unwrap_or_else(|| text.to_string())
637}
638
639fn as_argv(rule: &UserRule) -> Option<Rule> {
640 Some(Rule {
641 pattern: rule.pattern.clone()?,
642 verdict: rule.verdict.clone().unwrap_or_else(|| "ask".to_string()),
643 reason: rule.reason.clone(),
644 })
645}
646
647fn fired(rule: &UserRule) -> Rule {
648 let why = rule.reason.trim();
649 let text = rule.text.trim();
650 let reason = if text.is_empty() || text == why {
651 why.to_string()
652 } else {
653 format!("{text} ({why})")
654 };
655 Rule {
656 pattern: format!("user-rule:{}", rule.id),
657 verdict: "deny".into(),
658 reason,
659 }
660}
661
662#[must_use]
665pub fn mcp_event_denies(event: &str, rule: &Rule) -> bool {
666 event == "beforeMCPExecution"
667 && rule.verdict == "deny"
668 && rule.pattern.starts_with("user-rule:")
669}
670
671struct Post {
672 bodies: Vec<String>,
673 reply_values: Vec<String>,
674}
675
676#[must_use]
678pub fn guard_line(line: &str) -> Option<Rule> {
679 let rules = load().unwrap_or_default();
680 if rules.is_empty() || line.trim().is_empty() {
681 return None;
682 }
683 let argv: Vec<Rule> = rules.iter().filter_map(as_argv).collect();
684 if let Some(hit) = super::verdict_for(&argv, line).filter(|rule| rule.verdict == "deny") {
685 return Some(hit.clone());
686 }
687 if let Some(hit) = outbound(line, &rules) {
688 return Some(hit);
689 }
690 super::verdict_for(&argv, line).cloned()
691}
692
693#[must_use]
696pub fn guard_payload(raw: &str) -> Option<Rule> {
697 let value: Value = serde_json::from_str(raw.trim()).ok()?;
698 let tool = value
699 .get("tool_name")
700 .or_else(|| value.get("toolName"))
701 .and_then(Value::as_str)
702 .or_else(|| value.pointer("/toolCall/name").and_then(Value::as_str))?;
703 let server = ["mcp_server_name", "mcpServerName", "server_name", "server"]
704 .iter()
705 .find_map(|key| value.get(*key).and_then(Value::as_str));
706 let input = value
707 .get("tool_input")
708 .or_else(|| value.get("toolInput"))
709 .or_else(|| value.pointer("/toolCall/args"));
710 let parsed = match input {
711 Some(Value::String(text)) => {
712 serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone()))
713 }
714 Some(other) => other.clone(),
715 None => Value::Null,
716 };
717 guard_mcp(tool, server, &parsed)
718}
719
720fn guard_mcp(tool: &str, server: Option<&str>, input: &Value) -> Option<Rule> {
721 if !github_write(tool, server) {
722 return None;
723 }
724 let rules = load().unwrap_or_default();
725 let post = mcp_fields(input, tool);
726 rules.iter().find(|rule| post_hits(rule, &post)).map(fired)
727}
728
729fn github_write(tool: &str, server: Option<&str>) -> bool {
730 let tool_l = tool.to_ascii_lowercase();
731 let short = tool_l.rsplit("__").next().unwrap_or(&tool_l);
732 let short = short.rsplit('/').next().unwrap_or(short);
733 let githubish = server.is_some_and(|name| name.to_ascii_lowercase().contains("github"))
734 || tool_l.contains("github");
735 if !githubish {
736 return false;
737 }
738 if short.starts_with("get_")
739 || short.starts_with("list_")
740 || short.starts_with("search_")
741 || short.starts_with("read_")
742 || short.contains("_read")
743 {
744 return false;
745 }
746 ["comment", "review", "issue", "pull"]
747 .iter()
748 .any(|kind| short.contains(kind))
749}
750
751fn outbound(line: &str, rules: &[UserRule]) -> Option<Rule> {
752 let posts = posts_of(line);
753 if posts.is_empty() {
754 return None;
755 }
756 rules.iter().find_map(|rule| {
757 posts
758 .iter()
759 .any(|post| post_hits(rule, post))
760 .then(|| fired(rule))
761 })
762}
763
764fn post_hits(rule: &UserRule, post: &Post) -> bool {
765 let mentioned = post
766 .bodies
767 .iter()
768 .any(|body| mentions(&rule.deny_mention, body));
769 let replies = post
770 .reply_values
771 .iter()
772 .any(|value| identifies(&rule.deny_reply_to, value));
773 mentioned || replies
774}
775
776fn posts_of(line: &str) -> Vec<Post> {
777 let mut out = Vec::new();
778 for sub in super::nested_lines(line, 0) {
779 for raw in super::raw_segments(&sub) {
780 let words = super::shell_words(&raw);
781 if words.is_empty() {
782 continue;
783 }
784 let (at, _) = super::prefix_end(&words);
785 if at >= words.len() {
786 continue;
787 }
788 if let Some(post) = gh_post(&words[at..]) {
789 out.push(post);
790 }
791 }
792 }
793 out
794}
795
796fn gh_post(words: &[String]) -> Option<Post> {
797 let bin = words.first()?.rsplit(['/', '\\']).next()?;
798 if bin != "gh" {
799 return None;
800 }
801 let args = skip_global(&words[1..]);
802 let group = args.first()?.as_str();
803 let verb = args.get(1).map(String::as_str);
804 let write = match (group, verb) {
805 ("pr" | "issue", Some("comment" | "review" | "create" | "edit")) => true,
806 ("api", _) => api_write(&args[1..]),
807 _ => false,
808 };
809 if !write {
810 return None;
811 }
812 Some(Post {
813 bodies: bodies_of(&args),
814 reply_values: reply_values_of(&args),
815 })
816}
817
818fn skip_global(words: &[String]) -> Vec<String> {
819 let mut i = 0;
820 while i < words.len() {
821 let word = words[i].as_str();
822 if word == "--" {
823 return words[i + 1..].to_vec();
824 }
825 if !word.starts_with('-') {
826 break;
827 }
828 let takes_value = matches!(word, "-R" | "--repo" | "--hostname");
829 i += if word.contains('=') || !takes_value {
830 1
831 } else {
832 2
833 };
834 }
835 words[i..].to_vec()
836}
837
838fn api_write(args: &[String]) -> bool {
839 matches!(method_of(args).as_deref(), Some("POST" | "PATCH" | "PUT"))
840}
841
842fn method_of(args: &[String]) -> Option<String> {
843 let mut i = 0;
844 while i < args.len() {
845 let word = args[i].as_str();
846 if let Some(value) = word
847 .strip_prefix("--method=")
848 .or_else(|| word.strip_prefix("-X="))
849 {
850 return Some(value.to_ascii_uppercase());
851 }
852 if word == "--method" || word == "-X" {
853 return args.get(i + 1).map(|value| value.to_ascii_uppercase());
854 }
855 if let Some(value) = word.strip_prefix("-X") {
856 if !value.is_empty() && !value.starts_with('-') {
857 return Some(value.to_ascii_uppercase());
858 }
859 }
860 i += 1;
861 }
862 None
863}
864
865const BODY_FLAGS: &[&str] = &["--body", "-b", "--title", "-t", "--subject"];
866const FIELD_FLAGS: &[&str] = &["-f", "-F", "--field", "--raw-field"];
867const BODY_KEYS: &[&str] = &["body", "title", "comment", "review"];
868const REPLY_KEYS: &[&str] = &["in_reply_to", "in_reply_to_user", "reply_to", "reply-to"];
869
870fn bodies_of(args: &[String]) -> Vec<String> {
871 let mut out = flag_values(args, BODY_FLAGS);
872 for (key, value) in assignments(args) {
873 if BODY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
874 out.push(value);
875 }
876 }
877 out
878}
879
880fn reply_values_of(args: &[String]) -> Vec<String> {
881 let mut out = flag_values(args, &["--reply-to", "--reply-to-user"]);
882 for (key, value) in assignments(args) {
883 if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
884 out.push(value);
885 }
886 }
887 for arg in args {
888 if let Some((_, query)) = arg.split_once('?') {
889 for pair in query.split('&') {
890 if let Some((key, value)) = pair.split_once('=') {
891 if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
892 out.push(value.to_string());
893 }
894 }
895 }
896 }
897 }
898 out
899}
900
901fn assignments(args: &[String]) -> Vec<(String, String)> {
902 let mut out = Vec::new();
903 let mut i = 0;
904 while i < args.len() {
905 let word = &args[i];
906 if FIELD_FLAGS.contains(&word.as_str()) {
907 if let Some(next) = args.get(i + 1) {
908 if let Some((key, value)) = next.split_once('=') {
909 out.push((key.to_string(), value.to_string()));
910 }
911 }
912 i += 2;
913 continue;
914 }
915 if let Some(rest) = word
916 .strip_prefix("--raw-field=")
917 .or_else(|| word.strip_prefix("--field="))
918 {
919 if let Some((key, value)) = rest.split_once('=') {
920 out.push((key.to_string(), value.to_string()));
921 }
922 }
923 i += 1;
924 }
925 out
926}
927
928fn flag_values(args: &[String], names: &[&str]) -> Vec<String> {
929 let mut out = Vec::new();
930 let mut i = 0;
931 while i < args.len() {
932 let word = &args[i];
933 if let Some((key, value)) = word.split_once('=') {
934 if names.contains(&key) && !value.is_empty() {
935 out.push(value.to_string());
936 }
937 }
938 if names.contains(&word.as_str()) {
939 if let Some(next) = args.get(i + 1) {
940 if !next.starts_with('-') {
941 out.push(next.clone());
942 i += 2;
943 continue;
944 }
945 }
946 }
947 i += 1;
948 }
949 out
950}
951
952fn mcp_fields(input: &Value, tool: &str) -> Post {
953 let mut bodies = Vec::new();
954 let mut reply_values = Vec::new();
955 collect_keyed(
956 input,
957 &["body", "comment", "title", "review", "message", "content"],
958 &mut bodies,
959 );
960 collect_keyed(
961 input,
962 &[
963 "in_reply_to",
964 "in_reply_to_user",
965 "inReplyTo",
966 "reply_to",
967 "replyTo",
968 "user",
969 "author",
970 "reviewer",
971 ],
972 &mut reply_values,
973 );
974 if tool.to_ascii_lowercase().contains("reply") {
975 collect_strings(input, &mut reply_values);
976 }
977 Post {
978 bodies,
979 reply_values,
980 }
981}
982
983fn collect_keyed(value: &Value, keys: &[&str], out: &mut Vec<String>) {
984 match value {
985 Value::Object(map) => {
986 for (key, child) in map {
987 if keys.iter().any(|name| key.eq_ignore_ascii_case(name)) {
988 collect_strings(child, out);
989 } else {
990 collect_keyed(child, keys, out);
991 }
992 }
993 }
994 Value::Array(items) => {
995 for child in items {
996 collect_keyed(child, keys, out);
997 }
998 }
999 _ => {}
1000 }
1001}
1002
1003fn collect_strings(value: &Value, out: &mut Vec<String>) {
1004 match value {
1005 Value::String(text) => out.push(text.clone()),
1006 Value::Array(items) => {
1007 for child in items {
1008 collect_strings(child, out);
1009 }
1010 }
1011 Value::Object(map) => {
1012 for child in map.values() {
1013 collect_strings(child, out);
1014 }
1015 }
1016 _ => {}
1017 }
1018}
1019
1020fn mentions(people: &People, text: &str) -> bool {
1021 people
1022 .handles
1023 .iter()
1024 .any(|handle| mentions_handle(text, handle))
1025 || people.names.iter().any(|name| mentions_name(text, name))
1026}
1027
1028fn identifies(people: &People, value: &str) -> bool {
1029 let value = value.trim();
1030 if value.is_empty() {
1031 return false;
1032 }
1033 let bare = value.trim_start_matches('@').to_ascii_lowercase();
1034 people.handles.iter().any(|handle| {
1035 let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
1036 !handle.is_empty() && (bare == handle || mentions_handle(value, &handle))
1037 }) || people.names.iter().any(|name| mentions_name(value, name))
1038}
1039
1040fn mentions_handle(text: &str, handle: &str) -> bool {
1041 let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
1042 if handle.is_empty() {
1043 return false;
1044 }
1045 let lower = text.to_ascii_lowercase();
1046 let needle = format!("@{handle}");
1047 let bytes = lower.as_bytes();
1048 let mark = needle.as_bytes();
1049 let mut i = 0;
1050 while i + mark.len() <= bytes.len() {
1051 if &bytes[i..i + mark.len()] == mark {
1052 let after = bytes.get(i + mark.len()).copied();
1053 if after.is_none_or(|c| !is_handle_char(c as char)) {
1054 return true;
1055 }
1056 }
1057 i += 1;
1058 }
1059 false
1060}
1061
1062fn is_handle_char(c: char) -> bool {
1063 c.is_ascii_alphanumeric() || c == '-'
1064}
1065
1066fn mentions_name(text: &str, name: &str) -> bool {
1067 let name = name.trim();
1068 if name.is_empty() {
1069 return false;
1070 }
1071 let text: Vec<char> = text.to_lowercase().chars().collect();
1072 let name: Vec<char> = name.to_lowercase().chars().collect();
1073 let mut i = 0;
1074 while i + name.len() <= text.len() {
1075 if text[i..i + name.len()] == name[..] {
1076 let before = i == 0 || !text[i - 1].is_alphanumeric();
1077 let after = i + name.len();
1078 let after_ok = after == text.len() || !text[after].is_alphanumeric();
1079 if before && after_ok {
1080 return true;
1081 }
1082 }
1083 i += 1;
1084 }
1085 false
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use super::*;
1091
1092 fn isolated() -> (super::super::EnvLock, tempfile::TempDir) {
1093 let lock = super::super::test_env_lock();
1094 let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1097 .join("../../target/user-rule-homes");
1098 std::fs::create_dir_all(&root).unwrap();
1099 let dir = tempfile::TempDir::new_in(&root).unwrap();
1100 unsafe {
1101 std::env::set_var("XDG_CONFIG_HOME", dir.path().join("config"));
1102 std::env::set_var("HOME", dir.path());
1103 std::env::set_var("LJOS_HOLD", "rohit");
1104 std::env::set_var("LJOS_SEAT", "claude");
1105 std::env::set_var("PACKSET_URL", "off");
1106 std::env::set_var("PACKSET_WORKSPACE", "seat-a");
1107 std::env::set_var("LJOS_USER_RULES_SYNC", "off");
1108 }
1109 (lock, dir)
1110 }
1111
1112 fn alan() -> UserRule {
1113 write_preference(
1114 "never post or reply to Alan (ocaisa)",
1115 "he asked not to be pinged",
1116 &People {
1117 handles: vec!["ocaisa".into()],
1118 names: vec!["Alan".into()],
1119 },
1120 &People {
1121 handles: vec!["@ocaisa".into()],
1122 names: vec!["Alan".into()],
1123 },
1124 )
1125 .unwrap()
1126 }
1127
1128 #[test]
1129 fn a_user_rule_is_surfaced_in_a_second_seat() {
1130 let (_lock, dir) = isolated();
1131 let written = alan();
1132 assert_eq!(written.author, "rohit");
1133 assert!(written.due_at.is_none());
1134 unsafe {
1135 std::env::set_var("LJOS_SEAT", "codex");
1136 std::env::set_var("PACKSET_WORKSPACE", "seat-b");
1137 }
1138 let path = rules_path().unwrap();
1139 assert!(path.starts_with(dir.path().join("config")));
1140 assert!(!path.to_string_lossy().contains("codex"));
1141 assert!(!path.to_string_lossy().contains("claude"));
1142 let head = preface();
1143 assert!(head.starts_with("User rules"), "{head}");
1144 assert!(head.contains("never post or reply to Alan"), "{head}");
1145 let start = super::super::session_start_for(Some("seat-two"));
1146 assert!(start.starts_with("User rules"), "{start}");
1147 let at = start.find("never post or reply to Alan").unwrap();
1148 let on = start.find("ljos is on").unwrap();
1149 assert!(at < on, "{start}");
1150 let call = super::super::HookCall {
1151 event: "UserPromptSubmit".into(),
1152 cue: "open an easybuild pull request".into(),
1153 session: Some("seat-two".into()),
1154 shape: super::super::HookShape::Asks,
1155 };
1156 let (note, ids) = super::super::hook_note(&call, 5);
1157 assert!(note.starts_with("User rules"), "{note}");
1158 assert!(note.contains("never post or reply to Alan"), "{note}");
1159 assert!(ids.iter().all(|id| !id.starts_with("ur-")), "{ids:?}");
1160 let claude = super::super::hook_call(
1161 r#"{"hook_event_name":"UserPromptSubmit","session_id":"claude-1","prompt":"open an easybuild pull request"}"#,
1162 );
1163 let (claude_note, _) = super::super::hook_note(&claude, 5);
1164 let claude_out = super::super::hook_output(&claude, &claude_note);
1165 assert!(claude_out.contains("additionalContext"), "{claude_out}");
1166 assert!(
1167 claude_out.contains("never post or reply to Alan"),
1168 "{claude_out}"
1169 );
1170 let agy = super::super::hook_call_as(
1171 r#"{"conversationId":"agy-1","invocationNum":1,"prompt":"ship the easybuild change"}"#,
1172 Some("PreInvocation"),
1173 );
1174 assert_eq!(agy.event, "UserPromptSubmit");
1175 assert_eq!(agy.shape, super::super::HookShape::Steps);
1176 let (agy_note, _) = super::super::hook_note(&agy, 5);
1177 let agy_out = super::super::hook_output(&agy, &agy_note);
1178 assert!(agy_out.contains("injectSteps"), "{agy_out}");
1179 assert!(agy_out.contains("never post or reply to Alan"), "{agy_out}");
1180 unsafe {
1181 std::env::set_var("XDG_CONFIG_HOME", dir.path().join("other"));
1182 }
1183 assert!(preface().is_empty(), "a second config home has no rules");
1184 }
1185
1186 #[test]
1187 fn a_user_rule_does_not_decay_after_simulated_time() {
1188 let (_lock, _dir) = isolated();
1189 let written = alan();
1190 let later = "2099-01-01T00:00:00Z";
1191 assert_eq!(sweep(later).unwrap(), 0);
1192 assert_eq!(review_due(later), 0);
1193 assert!(!is_due(&written, later));
1194 let kept = load().unwrap();
1195 assert_eq!(kept.len(), 1);
1196 assert_eq!(kept[0].text, written.text);
1197 assert!(kept[0].due_at.is_none());
1198 let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
1199 assert!(!raw.contains("\"due_at\""), "{raw}");
1200 let mut file: File = serde_json::from_str(&raw).unwrap();
1201 file.rules.push(UserRule {
1202 id: "ur-expired".into(),
1203 text: "a row that was given a date".into(),
1204 author: "rohit".into(),
1205 reason: "fixture".into(),
1206 pattern: None,
1207 verdict: None,
1208 deny_mention: People::default(),
1209 deny_reply_to: People::default(),
1210 written_at: "2020-01-01T00:00:00Z".into(),
1211 updated_at: "2020-01-01T00:00:00Z".into(),
1212 deleted: false,
1213 from: None,
1214 due_at: Some("2020-01-02T00:00:00Z".into()),
1215 });
1216 std::fs::write(
1217 rules_path().unwrap(),
1218 serde_json::to_string_pretty(&file).unwrap(),
1219 )
1220 .unwrap();
1221 assert_eq!(sweep(later).unwrap(), 1);
1222 let kept = load().unwrap();
1223 assert_eq!(kept.len(), 1);
1224 assert_eq!(kept[0].id, written.id);
1225 assert_eq!(review_due(later), 0);
1226 }
1227
1228 #[test]
1229 fn the_preface_stays_inside_its_budget_and_keeps_the_first_rule() {
1230 let (_lock, _dir) = isolated();
1231 let first = "a".repeat(80);
1232 write_preference(&first, "first", &People::default(), &People::default()).unwrap();
1233 for n in 0..30 {
1234 let text = format!("rule {n} {}", "b".repeat(200));
1235 write_preference(&text, "later", &People::default(), &People::default()).unwrap();
1236 }
1237 let head = preface();
1238 assert!(
1239 head.chars().count() <= PREFACE_BUDGET,
1240 "{}",
1241 head.chars().count()
1242 );
1243 assert!(head.contains(&first[..40]), "{head}");
1244 assert!(head.contains("omitted"), "{head}");
1245 }
1246
1247 #[test]
1248 fn the_guard_denies_a_mention_and_a_reply_and_allows_another_comment() {
1249 let (_lock, _dir) = isolated();
1250 write_preference(
1251 "never post or reply to Alan (ocaisa)",
1252 "he asked not to be pinged",
1253 &People::default(),
1254 &People::default(),
1255 )
1256 .unwrap();
1257 let mention = "gh pr comment 12 --body 'please ask @ocaisa'";
1258 assert!(
1259 super::super::static_guard(mention).is_none(),
1260 "prose alone is not a deny"
1261 );
1262 alan();
1263 let denied = super::super::static_guard(mention).expect("mention");
1264 assert_eq!(denied.verdict, "deny");
1265 assert!(denied.pattern.starts_with("user-rule:"), "{denied:?}");
1266 assert!(
1267 denied.reason.contains("ocaisa") || denied.reason.contains("Alan"),
1268 "{denied:?}"
1269 );
1270 let reply = "gh api --method POST repos/org/repo/issues/comments/7/replies -f body=seen -F in_reply_to_user=ocaisa";
1271 let denied = super::super::static_guard(reply).expect("reply");
1272 assert_eq!(denied.verdict, "deny");
1273 let allowed = "gh pr comment 12 --body 'tests passed'";
1274 assert!(
1275 super::super::static_guard(allowed).is_none(),
1276 "unrelated comment"
1277 );
1278 assert!(super::super::static_guard("gh pr comment 12 --body 'see ocaisa'").is_none());
1279 assert!(
1280 super::super::static_guard("gh pr comment 12 --body 'please ask @ocaisa2'").is_none()
1281 );
1282 assert!(
1283 super::super::static_guard("gh pr comment 12 --body 'alanine is a molecule'").is_none()
1284 );
1285 let named =
1286 super::super::static_guard("gh pr comment 12 --body 'ask Alan to look'").expect("name");
1287 assert_eq!(named.verdict, "deny");
1288 assert!(super::super::static_guard("gh api user/ocaisa").is_none());
1289 assert!(super::super::static_guard("echo please ask @ocaisa").is_none());
1290 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"}}"#;
1291 assert!(guard_payload(mcp).is_some());
1292 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"}}"#;
1293 assert!(guard_payload(mcp_reply).is_some());
1294 let mcp_ok = r#"{"tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"tests passed"}}"#;
1295 assert!(guard_payload(mcp_ok).is_none());
1296 let mcp_read = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"body":"@ocaisa"}}"#;
1297 assert!(guard_payload(mcp_read).is_none());
1298 let cursor = super::super::hook_call(
1299 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"}}"#,
1300 );
1301 let verdict = guard_payload(
1302 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"}}"#,
1303 )
1304 .unwrap();
1305 let out = super::super::hook_output_ruled(&cursor, "", Some(&verdict));
1306 assert!(out.contains("\"permission\":\"deny\""), "{out}");
1307 }
1308
1309 #[test]
1310 fn promote_copies_a_lesson_and_refuses_anything_else() {
1311 let (_lock, _dir) = isolated();
1312 let atom = serde_json::json!({
1313 "id": "lesson-ocaisa",
1314 "kind": "lesson",
1315 "text": "Never post or reply to Alan (ocaisa).",
1316 "due_at": "2026-10-18T00:00:00Z"
1317 });
1318 let rule = promote_atom(
1319 &atom,
1320 "Alan asked that this hold for every seat",
1321 &People {
1322 handles: vec!["ocaisa".into()],
1323 names: vec!["Alan".into()],
1324 },
1325 &People {
1326 handles: vec!["ocaisa".into()],
1327 names: vec![],
1328 },
1329 )
1330 .unwrap();
1331 assert_eq!(rule.text, "Never post or reply to Alan (ocaisa).");
1332 assert_eq!(rule.author, "rohit");
1333 assert_eq!(rule.from.as_deref(), Some("lesson-ocaisa"));
1334 assert!(rule.due_at.is_none());
1335 assert_eq!(rule.deny_mention.handles, ["ocaisa"]);
1336 let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
1337 assert!(!raw.contains("\"due_at\""), "{raw}");
1338 let preference = serde_json::json!({"id": "pref-1", "kind": "preference", "text": "no"});
1339 let err = promote_atom(
1340 &preference,
1341 "because",
1342 &People::default(),
1343 &People::default(),
1344 )
1345 .unwrap_err()
1346 .to_string();
1347 assert!(err.contains("only a lesson"), "{err}");
1348 let err = promote_atom(&atom, " ", &People::default(), &People::default())
1349 .unwrap_err()
1350 .to_string();
1351 assert!(err.contains("reason"), "{err}");
1352 }
1353
1354 #[test]
1355 fn an_argv_user_rule_denies_in_the_static_guard() {
1356 let (_lock, _dir) = isolated();
1357 write_argv(&Rule {
1358 pattern: "gh pr comment*".into(),
1359 verdict: "deny".into(),
1360 reason: "no pull request comments from this seat".into(),
1361 })
1362 .unwrap();
1363 let denied = super::super::static_guard("gh pr comment 12 --body 'tests passed'").unwrap();
1364 assert_eq!(denied.verdict, "deny");
1365 assert_eq!(denied.pattern, "gh pr comment*");
1366 assert!(super::super::static_guard("gh issue list").is_none());
1367 }
1368
1369 fn git_ok(dir: &std::path::Path, args: &[&str]) {
1370 let out = std::process::Command::new("git")
1371 .arg("-C")
1372 .arg(dir)
1373 .args(args)
1374 .output()
1375 .unwrap();
1376 assert!(
1377 out.status.success(),
1378 "git {args:?} in {}: {}{}",
1379 dir.display(),
1380 String::from_utf8_lossy(&out.stderr),
1381 String::from_utf8_lossy(&out.stdout)
1382 );
1383 }
1384
1385 fn prepare_clone(dir: &std::path::Path) {
1386 git_ok(dir, &["config", "user.email", "seat@example.invalid"]);
1387 git_ok(dir, &["config", "user.name", "seat"]);
1388 git_ok(dir, &["config", "commit.gpgsign", "false"]);
1389 git_ok(dir, &["config", "core.hooksPath", "/dev/null"]);
1390 }
1391
1392 struct Homes {
1393 _lock: super::super::EnvLock,
1394 _root: tempfile::TempDir,
1395 a: std::path::PathBuf,
1396 b: std::path::PathBuf,
1397 cfg_a: std::path::PathBuf,
1398 cfg_b: std::path::PathBuf,
1399 }
1400
1401 impl Homes {
1402 fn new() -> Self {
1403 let lock = super::super::test_env_lock();
1404 let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1405 .join("../../target/user-rule-homes");
1406 std::fs::create_dir_all(&base).unwrap();
1407 let root = tempfile::TempDir::new_in(&base).unwrap();
1408 let seed = root.path().join("seed");
1409 std::fs::create_dir_all(&seed).unwrap();
1410 git_ok(&seed, &["init", "-q", "-b", "main"]);
1411 prepare_clone(&seed);
1412 git_ok(&seed, &["commit", "--allow-empty", "-q", "-m", "init"]);
1413 let bare = root.path().join("bare.git");
1414 let laptop = root.path().join("laptop");
1415 let shared = root.path().join("shared");
1416 git_ok(
1417 root.path(),
1418 &[
1419 "clone",
1420 "--bare",
1421 "-q",
1422 seed.to_str().unwrap(),
1423 bare.to_str().unwrap(),
1424 ],
1425 );
1426 git_ok(
1427 root.path(),
1428 &[
1429 "clone",
1430 "-q",
1431 bare.to_str().unwrap(),
1432 laptop.to_str().unwrap(),
1433 ],
1434 );
1435 git_ok(
1436 root.path(),
1437 &[
1438 "clone",
1439 "-q",
1440 bare.to_str().unwrap(),
1441 shared.to_str().unwrap(),
1442 ],
1443 );
1444 prepare_clone(&laptop);
1445 prepare_clone(&shared);
1446 let cfg_a = root.path().join("cfg-a");
1447 let cfg_b = root.path().join("cfg-b");
1448 unsafe {
1449 std::env::set_var("LJOS_USER_RULES_SYNC", "off");
1450 std::env::set_var("LJOS_HOLD", "rohit");
1451 std::env::set_var("PACKSET_URL", "off");
1452 std::env::set_var("HOME", root.path());
1453 std::env::remove_var("LJOS_USER_RULES_TRACKER");
1454 }
1455 Self {
1456 _lock: lock,
1457 _root: root,
1458 a: laptop,
1459 b: shared,
1460 cfg_a,
1461 cfg_b,
1462 }
1463 }
1464
1465 fn home(&self, cfg: &std::path::Path) {
1466 unsafe { std::env::set_var("XDG_CONFIG_HOME", cfg) };
1467 }
1468 }
1469
1470 fn exchange(tracker: &std::path::Path, host: &str) {
1471 let said = super::super::sync::exchange_user_rules(
1472 tracker,
1473 host,
1474 std::time::Duration::from_secs(8),
1475 )
1476 .unwrap_or_else(|err| panic!("sync {host}: {err:#}"));
1477 assert!(!said.contains("did not answer"), "{said}");
1478 }
1479
1480 fn rewrite(id: &str, text: &str, author: &str, reason: &str, at: &str, deleted: bool) {
1481 let mut rows = read_all().unwrap();
1482 let rule = rows.iter_mut().find(|rule| rule.id == id).unwrap();
1483 rule.text = text.to_string();
1484 rule.author = author.to_string();
1485 rule.reason = reason.to_string();
1486 rule.updated_at = at.to_string();
1487 rule.deleted = deleted;
1488 store(&rows).unwrap();
1489 }
1490
1491 #[test]
1492 fn two_homes_with_different_rules_converge() {
1493 let homes = Homes::new();
1494 homes.home(&homes.cfg_a);
1495 let alan = write_preference(
1496 "never post or reply to Alan (ocaisa)",
1497 "he asked not to be pinged",
1498 &People::default(),
1499 &People::default(),
1500 )
1501 .unwrap();
1502 exchange(&homes.a, "laptop");
1503 homes.home(&homes.cfg_b);
1504 exchange(&homes.b, "shared");
1505 let got = load().unwrap();
1506 assert_eq!(got.len(), 1, "{got:?}");
1507 assert_eq!(got[0].id, alan.id);
1508 assert_eq!(got[0].author, "rohit");
1509 assert_eq!(got[0].reason, "he asked not to be pinged");
1510 let credit = write_preference(
1511 "EasyBuild pull requests name eb-stack and gpt-oss-120b",
1512 "the credit line is part of the work",
1513 &People::default(),
1514 &People::default(),
1515 )
1516 .unwrap();
1517 assert_eq!(credit.id, load().unwrap()[1].id);
1518 exchange(&homes.b, "shared");
1519 homes.home(&homes.cfg_a);
1520 exchange(&homes.a, "laptop");
1521 let got = load().unwrap();
1522 assert!(got.iter().any(|rule| rule.id == alan.id), "{got:?}");
1523 assert!(got.iter().any(|rule| rule.id == credit.id), "{got:?}");
1524 homes.home(&homes.cfg_b);
1525 let got = load().unwrap();
1526 assert_eq!(got.len(), 2, "{got:?}");
1527 assert!(got.iter().any(|rule| rule.id == alan.id));
1528 assert!(got.iter().any(|rule| rule.id == credit.id));
1529 }
1530
1531 #[test]
1532 fn a_newer_edit_wins_and_a_tombstone_beats_an_older_edit() {
1533 let homes = Homes::new();
1534 homes.home(&homes.cfg_a);
1535 let rule = write_preference(
1536 "the standing line",
1537 "first reason",
1538 &People::default(),
1539 &People::default(),
1540 )
1541 .unwrap();
1542 let id = rule.id.clone();
1543 exchange(&homes.a, "laptop");
1544 homes.home(&homes.cfg_b);
1545 exchange(&homes.b, "shared");
1546
1547 rewrite(
1548 &id,
1549 "older edit",
1550 "ada",
1551 "because older",
1552 "2099-01-01T00:00:01.000Z",
1553 false,
1554 );
1555 exchange(&homes.b, "shared");
1556 homes.home(&homes.cfg_a);
1557 rewrite(
1558 &id,
1559 "newest edit",
1560 "rohit",
1561 "because newest",
1562 "2099-01-01T00:00:09.000Z",
1563 false,
1564 );
1565 exchange(&homes.a, "laptop");
1566 homes.home(&homes.cfg_b);
1567 exchange(&homes.b, "shared");
1568 let got = load().unwrap();
1569 assert_eq!(got.len(), 1, "{got:?}");
1570 assert_eq!(got[0].id, id);
1571 assert_eq!(got[0].text, "newest edit");
1572 assert_eq!(got[0].author, "rohit");
1573 assert_eq!(got[0].reason, "because newest");
1574 homes.home(&homes.cfg_a);
1575 let got = load().unwrap();
1576 assert_eq!(got[0].text, "newest edit");
1577 assert_eq!(got[0].author, "rohit");
1578 assert_eq!(got[0].reason, "because newest");
1579
1580 let removed = forget(&id).unwrap();
1581 assert!(removed.deleted);
1582 assert_eq!(removed.author, "rohit");
1583 assert_eq!(removed.reason, "because newest");
1584 rewrite(
1585 &id,
1586 "newest edit",
1587 "rohit",
1588 "because newest",
1589 "2099-01-02T00:00:00.000Z",
1590 true,
1591 );
1592 exchange(&homes.a, "laptop");
1593 homes.home(&homes.cfg_b);
1594 exchange(&homes.b, "shared");
1595 assert!(load().unwrap().is_empty(), "{:?}", load().unwrap());
1596 let tomb = read_all().unwrap();
1597 assert_eq!(tomb.len(), 1);
1598 assert!(tomb[0].deleted);
1599 assert_eq!(tomb[0].author, "rohit");
1600 assert_eq!(tomb[0].reason, "because newest");
1601 assert!(!preface().contains("newest edit"));
1602 rewrite(
1603 &id,
1604 "older edit",
1605 "ada",
1606 "because older",
1607 "2099-01-01T00:00:01.000Z",
1608 false,
1609 );
1610 exchange(&homes.b, "shared");
1611 assert!(
1612 load().unwrap().is_empty(),
1613 "a tombstone keeps the older edit out"
1614 );
1615 let tomb = read_all().unwrap();
1616 assert!(tomb[0].deleted);
1617 assert_eq!(tomb[0].updated_at, "2099-01-02T00:00:00.000Z");
1618 }
1619
1620 #[test]
1621 fn the_hook_shows_local_rules_when_the_tracker_does_not_answer() {
1622 let (_lock, dir) = isolated();
1623 write_preference(
1624 "never post or reply to Alan (ocaisa)",
1625 "he asked not to be pinged",
1626 &People::default(),
1627 &People::default(),
1628 )
1629 .unwrap();
1630 let repo = dir.path().join("tracker");
1631 std::fs::create_dir_all(&repo).unwrap();
1632 git_ok(&repo, &["init", "-q", "-b", "main"]);
1633 prepare_clone(&repo);
1634 git_ok(&repo, &["commit", "--allow-empty", "-q", "-m", "init"]);
1635 git_ok(&repo, &["config", "http.lowSpeedLimit", "1"]);
1636 git_ok(&repo, &["config", "http.lowSpeedTime", "1"]);
1637 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1638 let port = listener.local_addr().unwrap().port();
1639 std::thread::spawn(move || {
1640 let Ok((mut stream, _)) = listener.accept() else {
1641 return;
1642 };
1643 let mut buf = [0u8; 64];
1644 let _ = std::io::Read::read(&mut stream, &mut buf);
1645 });
1646 let url = format!("http://127.0.0.1:{port}/tracker.git");
1647 git_ok(&repo, &["remote", "add", "origin", &url]);
1648 unsafe {
1649 std::env::remove_var("LJOS_USER_RULES_SYNC");
1650 std::env::set_var("LJOS_USER_RULES_TRACKER", &repo);
1651 std::env::set_var("LJOS_USER_RULES_SYNC_MS", "400");
1652 }
1653 let started = std::time::Instant::now();
1654 let line = super::super::session_start_for(Some("seat-two"));
1655 let elapsed = started.elapsed();
1656 assert!(
1657 elapsed < std::time::Duration::from_secs(3),
1658 "{elapsed:?}\n{line}"
1659 );
1660 assert!(line.starts_with("User rules"), "{line}");
1661 assert!(line.contains("never post or reply to Alan"), "{line}");
1662 }
1663}