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, false);
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, false);
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(
502 &root, &host, budget, true,
503 )?);
504 }
505 if out.is_empty() {
506 out.push_str("sync: user rules: none\n");
507 }
508 Ok(out)
509}
510
511#[must_use]
513pub fn ack(rule: &UserRule) -> String {
514 if rule.deleted {
515 format!("user rule {}\tremoved\t{}", rule.id, rule.text)
516 } else {
517 format!("user rule {}\t{}", rule.id, rule.text)
518 }
519}
520
521#[must_use]
523pub fn to_value(rule: &UserRule) -> Value {
524 serde_json::to_value(rule).unwrap_or(Value::Null)
525}
526
527#[must_use]
529pub fn format_list() -> String {
530 let Ok(rules) = load() else {
531 return String::new();
532 };
533 if rules.is_empty() {
534 return String::new();
535 }
536 let mut out = String::from("user rules (shared, never reviewed):\n");
537 for rule in &rules {
538 let law = match (&rule.verdict, &rule.pattern) {
539 (Some(verdict), Some(pattern)) => format!("{verdict}\t{pattern}\t"),
540 _ => String::new(),
541 };
542 out.push_str(&format!(
543 "user\t{law}{}\t{}\t{}\n",
544 rule.text, rule.author, rule.reason
545 ));
546 }
547 out
548}
549
550fn one_line(text: &str) -> String {
551 text.split_whitespace().collect::<Vec<_>>().join(" ")
552}
553
554fn omission(left: usize) -> String {
555 if left == 0 {
556 String::new()
557 } else {
558 format!("{left} user rules omitted.\n")
559 }
560}
561
562#[must_use]
564pub fn preface() -> String {
565 render(&load().unwrap_or_default(), PREFACE_BUDGET)
566}
567
568fn render(rules: &[UserRule], budget: usize) -> String {
569 if rules.is_empty() || budget == 0 {
570 return String::new();
571 }
572 let header = "User rules (standing, shared by every seat on this machine):\n";
573 let mut body = String::new();
574 let mut shown = 0usize;
575 for rule in rules {
576 let line = format!("- {}\n", one_line(&rule.text));
577 let note = omission(rules.len() - (shown + 1));
578 let used = header.chars().count()
579 + body.chars().count()
580 + line.chars().count()
581 + note.chars().count();
582 if used <= budget {
583 body.push_str(&line);
584 shown += 1;
585 continue;
586 }
587 if shown == 0 {
588 let note = omission(rules.len().saturating_sub(1));
589 let room = budget.saturating_sub(header.chars().count() + note.chars().count());
590 let take = room.saturating_sub(6);
591 if take > 0 {
592 let cut: String = one_line(&rule.text).chars().take(take).collect();
593 body.push_str(&format!("- {cut}...\n"));
594 shown = 1;
595 }
596 }
597 break;
598 }
599 if shown == 0 {
600 return String::new();
601 }
602 let mut out = format!("{header}{body}");
603 let left = rules.len() - shown;
604 if left > 0 {
605 out.push_str(&omission(left));
606 }
607 if out.chars().count() > budget {
608 out = out.chars().take(budget).collect();
609 }
610 out.trim_end().to_string()
611}
612
613#[must_use]
616pub fn prepend(body: String) -> String {
617 let head = preface();
618 if head.is_empty() {
619 body
620 } else if body.is_empty() {
621 head
622 } else {
623 format!("{head}\n{body}")
624 }
625}
626
627#[must_use]
630pub fn without_preface(text: &str) -> String {
631 let head = preface();
632 if head.is_empty() {
633 return text.to_string();
634 }
635 text.trim_start()
636 .strip_prefix(&head)
637 .map(|rest| rest.trim_start().to_string())
638 .unwrap_or_else(|| text.to_string())
639}
640
641fn as_argv(rule: &UserRule) -> Option<Rule> {
642 Some(Rule {
643 pattern: rule.pattern.clone()?,
644 verdict: rule.verdict.clone().unwrap_or_else(|| "ask".to_string()),
645 reason: rule.reason.clone(),
646 })
647}
648
649fn fired(rule: &UserRule) -> Rule {
650 let why = rule.reason.trim();
651 let text = rule.text.trim();
652 let reason = if text.is_empty() || text == why {
653 why.to_string()
654 } else {
655 format!("{text} ({why})")
656 };
657 Rule {
658 pattern: format!("user-rule:{}", rule.id),
659 verdict: "deny".into(),
660 reason,
661 }
662}
663
664#[must_use]
667pub fn mcp_event_denies(event: &str, rule: &Rule) -> bool {
668 event == "beforeMCPExecution"
669 && rule.verdict == "deny"
670 && rule.pattern.starts_with("user-rule:")
671}
672
673struct Post {
674 bodies: Vec<String>,
675 reply_values: Vec<String>,
676}
677
678#[must_use]
680pub fn guard_line(line: &str) -> Option<Rule> {
681 let rules = load().unwrap_or_default();
682 if rules.is_empty() || line.trim().is_empty() {
683 return None;
684 }
685 let argv: Vec<Rule> = rules.iter().filter_map(as_argv).collect();
686 if let Some(hit) = super::verdict_for(&argv, line).filter(|rule| rule.verdict == "deny") {
687 return Some(hit.clone());
688 }
689 if let Some(hit) = outbound(line, &rules) {
690 return Some(hit);
691 }
692 super::verdict_for(&argv, line).cloned()
693}
694
695#[must_use]
698pub fn guard_payload(raw: &str) -> Option<Rule> {
699 let value: Value = serde_json::from_str(raw.trim()).ok()?;
700 let tool = value
701 .get("tool_name")
702 .or_else(|| value.get("toolName"))
703 .and_then(Value::as_str)
704 .or_else(|| value.pointer("/toolCall/name").and_then(Value::as_str))?;
705 let server = ["mcp_server_name", "mcpServerName", "server_name", "server"]
706 .iter()
707 .find_map(|key| value.get(*key).and_then(Value::as_str));
708 let input = value
709 .get("tool_input")
710 .or_else(|| value.get("toolInput"))
711 .or_else(|| value.pointer("/toolCall/args"));
712 let parsed = match input {
713 Some(Value::String(text)) => {
714 serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone()))
715 }
716 Some(other) => other.clone(),
717 None => Value::Null,
718 };
719 guard_mcp(tool, server, &parsed)
720}
721
722fn guard_mcp(tool: &str, server: Option<&str>, input: &Value) -> Option<Rule> {
723 if !github_write(tool, server) {
724 return None;
725 }
726 let rules = load().unwrap_or_default();
727 let post = mcp_fields(input, tool);
728 rules.iter().find(|rule| post_hits(rule, &post)).map(fired)
729}
730
731fn github_write(tool: &str, server: Option<&str>) -> bool {
732 let tool_l = tool.to_ascii_lowercase();
733 let short = tool_l.rsplit("__").next().unwrap_or(&tool_l);
734 let short = short.rsplit('/').next().unwrap_or(short);
735 let githubish = server.is_some_and(|name| name.to_ascii_lowercase().contains("github"))
736 || tool_l.contains("github");
737 if !githubish {
738 return false;
739 }
740 if short.starts_with("get_")
741 || short.starts_with("list_")
742 || short.starts_with("search_")
743 || short.starts_with("read_")
744 || short.contains("_read")
745 {
746 return false;
747 }
748 ["comment", "review", "issue", "pull"]
749 .iter()
750 .any(|kind| short.contains(kind))
751}
752
753fn outbound(line: &str, rules: &[UserRule]) -> Option<Rule> {
754 let posts = posts_of(line);
755 if posts.is_empty() {
756 return None;
757 }
758 rules.iter().find_map(|rule| {
759 posts
760 .iter()
761 .any(|post| post_hits(rule, post))
762 .then(|| fired(rule))
763 })
764}
765
766fn post_hits(rule: &UserRule, post: &Post) -> bool {
767 let mentioned = post
768 .bodies
769 .iter()
770 .any(|body| mentions(&rule.deny_mention, body));
771 let replies = post
772 .reply_values
773 .iter()
774 .any(|value| identifies(&rule.deny_reply_to, value));
775 mentioned || replies
776}
777
778fn posts_of(line: &str) -> Vec<Post> {
779 let mut out = Vec::new();
780 for sub in super::nested_lines(line, 0) {
781 for raw in super::raw_segments(&sub) {
782 let words = super::shell_words(&raw);
783 if words.is_empty() {
784 continue;
785 }
786 let (at, _) = super::prefix_end(&words);
787 if at >= words.len() {
788 continue;
789 }
790 if let Some(post) = gh_post(&words[at..]) {
791 out.push(post);
792 }
793 }
794 }
795 out
796}
797
798fn gh_post(words: &[String]) -> Option<Post> {
799 let bin = words.first()?.rsplit(['/', '\\']).next()?;
800 if bin != "gh" {
801 return None;
802 }
803 let args = skip_global(&words[1..]);
804 let group = args.first()?.as_str();
805 let verb = args.get(1).map(String::as_str);
806 let write = match (group, verb) {
807 ("pr" | "issue", Some("comment" | "review" | "create" | "edit")) => true,
808 ("api", _) => api_write(&args[1..]),
809 _ => false,
810 };
811 if !write {
812 return None;
813 }
814 Some(Post {
815 bodies: bodies_of(&args),
816 reply_values: reply_values_of(&args),
817 })
818}
819
820fn skip_global(words: &[String]) -> Vec<String> {
821 let mut i = 0;
822 while i < words.len() {
823 let word = words[i].as_str();
824 if word == "--" {
825 return words[i + 1..].to_vec();
826 }
827 if !word.starts_with('-') {
828 break;
829 }
830 let takes_value = matches!(word, "-R" | "--repo" | "--hostname");
831 i += if word.contains('=') || !takes_value {
832 1
833 } else {
834 2
835 };
836 }
837 words[i..].to_vec()
838}
839
840fn api_write(args: &[String]) -> bool {
841 matches!(method_of(args).as_deref(), Some("POST" | "PATCH" | "PUT"))
842}
843
844fn method_of(args: &[String]) -> Option<String> {
845 let mut i = 0;
846 while i < args.len() {
847 let word = args[i].as_str();
848 if let Some(value) = word
849 .strip_prefix("--method=")
850 .or_else(|| word.strip_prefix("-X="))
851 {
852 return Some(value.to_ascii_uppercase());
853 }
854 if word == "--method" || word == "-X" {
855 return args.get(i + 1).map(|value| value.to_ascii_uppercase());
856 }
857 if let Some(value) = word.strip_prefix("-X") {
858 if !value.is_empty() && !value.starts_with('-') {
859 return Some(value.to_ascii_uppercase());
860 }
861 }
862 i += 1;
863 }
864 None
865}
866
867const BODY_FLAGS: &[&str] = &["--body", "-b", "--title", "-t", "--subject"];
868const FIELD_FLAGS: &[&str] = &["-f", "-F", "--field", "--raw-field"];
869const BODY_KEYS: &[&str] = &["body", "title", "comment", "review"];
870const REPLY_KEYS: &[&str] = &["in_reply_to", "in_reply_to_user", "reply_to", "reply-to"];
871
872fn bodies_of(args: &[String]) -> Vec<String> {
873 let mut out = flag_values(args, BODY_FLAGS);
874 for (key, value) in assignments(args) {
875 if BODY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
876 out.push(value);
877 }
878 }
879 out
880}
881
882fn reply_values_of(args: &[String]) -> Vec<String> {
883 let mut out = flag_values(args, &["--reply-to", "--reply-to-user"]);
884 for (key, value) in assignments(args) {
885 if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
886 out.push(value);
887 }
888 }
889 for arg in args {
890 if let Some((_, query)) = arg.split_once('?') {
891 for pair in query.split('&') {
892 if let Some((key, value)) = pair.split_once('=') {
893 if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
894 out.push(value.to_string());
895 }
896 }
897 }
898 }
899 }
900 out
901}
902
903fn assignments(args: &[String]) -> Vec<(String, String)> {
904 let mut out = Vec::new();
905 let mut i = 0;
906 while i < args.len() {
907 let word = &args[i];
908 if FIELD_FLAGS.contains(&word.as_str()) {
909 if let Some(next) = args.get(i + 1) {
910 if let Some((key, value)) = next.split_once('=') {
911 out.push((key.to_string(), value.to_string()));
912 }
913 }
914 i += 2;
915 continue;
916 }
917 if let Some(rest) = word
918 .strip_prefix("--raw-field=")
919 .or_else(|| word.strip_prefix("--field="))
920 {
921 if let Some((key, value)) = rest.split_once('=') {
922 out.push((key.to_string(), value.to_string()));
923 }
924 }
925 i += 1;
926 }
927 out
928}
929
930fn flag_values(args: &[String], names: &[&str]) -> Vec<String> {
931 let mut out = Vec::new();
932 let mut i = 0;
933 while i < args.len() {
934 let word = &args[i];
935 if let Some((key, value)) = word.split_once('=') {
936 if names.contains(&key) && !value.is_empty() {
937 out.push(value.to_string());
938 }
939 }
940 if names.contains(&word.as_str()) {
941 if let Some(next) = args.get(i + 1) {
942 if !next.starts_with('-') {
943 out.push(next.clone());
944 i += 2;
945 continue;
946 }
947 }
948 }
949 i += 1;
950 }
951 out
952}
953
954fn mcp_fields(input: &Value, tool: &str) -> Post {
955 let mut bodies = Vec::new();
956 let mut reply_values = Vec::new();
957 collect_keyed(
958 input,
959 &["body", "comment", "title", "review", "message", "content"],
960 &mut bodies,
961 );
962 collect_keyed(
963 input,
964 &[
965 "in_reply_to",
966 "in_reply_to_user",
967 "inReplyTo",
968 "reply_to",
969 "replyTo",
970 "user",
971 "author",
972 "reviewer",
973 ],
974 &mut reply_values,
975 );
976 if tool.to_ascii_lowercase().contains("reply") {
977 collect_strings(input, &mut reply_values);
978 }
979 Post {
980 bodies,
981 reply_values,
982 }
983}
984
985fn collect_keyed(value: &Value, keys: &[&str], out: &mut Vec<String>) {
986 match value {
987 Value::Object(map) => {
988 for (key, child) in map {
989 if keys.iter().any(|name| key.eq_ignore_ascii_case(name)) {
990 collect_strings(child, out);
991 } else {
992 collect_keyed(child, keys, out);
993 }
994 }
995 }
996 Value::Array(items) => {
997 for child in items {
998 collect_keyed(child, keys, out);
999 }
1000 }
1001 _ => {}
1002 }
1003}
1004
1005fn collect_strings(value: &Value, out: &mut Vec<String>) {
1006 match value {
1007 Value::String(text) => out.push(text.clone()),
1008 Value::Array(items) => {
1009 for child in items {
1010 collect_strings(child, out);
1011 }
1012 }
1013 Value::Object(map) => {
1014 for child in map.values() {
1015 collect_strings(child, out);
1016 }
1017 }
1018 _ => {}
1019 }
1020}
1021
1022fn mentions(people: &People, text: &str) -> bool {
1023 people
1024 .handles
1025 .iter()
1026 .any(|handle| mentions_handle(text, handle))
1027 || people.names.iter().any(|name| mentions_name(text, name))
1028}
1029
1030fn identifies(people: &People, value: &str) -> bool {
1031 let value = value.trim();
1032 if value.is_empty() {
1033 return false;
1034 }
1035 let bare = value.trim_start_matches('@').to_ascii_lowercase();
1036 people.handles.iter().any(|handle| {
1037 let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
1038 !handle.is_empty() && (bare == handle || mentions_handle(value, &handle))
1039 }) || people.names.iter().any(|name| mentions_name(value, name))
1040}
1041
1042fn mentions_handle(text: &str, handle: &str) -> bool {
1043 let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
1044 if handle.is_empty() {
1045 return false;
1046 }
1047 let lower = text.to_ascii_lowercase();
1048 let needle = format!("@{handle}");
1049 let bytes = lower.as_bytes();
1050 let mark = needle.as_bytes();
1051 let mut i = 0;
1052 while i + mark.len() <= bytes.len() {
1053 if &bytes[i..i + mark.len()] == mark {
1054 let after = bytes.get(i + mark.len()).copied();
1055 if after.is_none_or(|c| !is_handle_char(c as char)) {
1056 return true;
1057 }
1058 }
1059 i += 1;
1060 }
1061 false
1062}
1063
1064fn is_handle_char(c: char) -> bool {
1065 c.is_ascii_alphanumeric() || c == '-'
1066}
1067
1068fn mentions_name(text: &str, name: &str) -> bool {
1069 let name = name.trim();
1070 if name.is_empty() {
1071 return false;
1072 }
1073 let text: Vec<char> = text.to_lowercase().chars().collect();
1074 let name: Vec<char> = name.to_lowercase().chars().collect();
1075 let mut i = 0;
1076 while i + name.len() <= text.len() {
1077 if text[i..i + name.len()] == name[..] {
1078 let before = i == 0 || !text[i - 1].is_alphanumeric();
1079 let after = i + name.len();
1080 let after_ok = after == text.len() || !text[after].is_alphanumeric();
1081 if before && after_ok {
1082 return true;
1083 }
1084 }
1085 i += 1;
1086 }
1087 false
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093
1094 fn isolated() -> (super::super::EnvLock, tempfile::TempDir) {
1095 let lock = super::super::test_env_lock();
1096 let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1099 .join("../../target/user-rule-homes");
1100 std::fs::create_dir_all(&root).unwrap();
1101 let dir = tempfile::TempDir::new_in(&root).unwrap();
1102 unsafe {
1103 std::env::set_var("XDG_CONFIG_HOME", dir.path().join("config"));
1104 std::env::set_var("HOME", dir.path());
1105 std::env::set_var("LJOS_HOLD", "rohit");
1106 std::env::set_var("LJOS_SEAT", "claude");
1107 std::env::set_var("PACKSET_URL", "off");
1108 std::env::set_var("PACKSET_WORKSPACE", "seat-a");
1109 std::env::set_var("LJOS_USER_RULES_SYNC", "off");
1110 }
1111 (lock, dir)
1112 }
1113
1114 fn alan() -> UserRule {
1115 write_preference(
1116 "never post or reply to Alan (ocaisa)",
1117 "he asked not to be pinged",
1118 &People {
1119 handles: vec!["ocaisa".into()],
1120 names: vec!["Alan".into()],
1121 },
1122 &People {
1123 handles: vec!["@ocaisa".into()],
1124 names: vec!["Alan".into()],
1125 },
1126 )
1127 .unwrap()
1128 }
1129
1130 #[test]
1131 fn a_user_rule_is_surfaced_in_a_second_seat() {
1132 let (_lock, dir) = isolated();
1133 let written = alan();
1134 assert_eq!(written.author, "rohit");
1135 assert!(written.due_at.is_none());
1136 unsafe {
1137 std::env::set_var("LJOS_SEAT", "codex");
1138 std::env::set_var("PACKSET_WORKSPACE", "seat-b");
1139 }
1140 let path = rules_path().unwrap();
1141 assert!(path.starts_with(dir.path().join("config")));
1142 assert!(!path.to_string_lossy().contains("codex"));
1143 assert!(!path.to_string_lossy().contains("claude"));
1144 let head = preface();
1145 assert!(head.starts_with("User rules"), "{head}");
1146 assert!(head.contains("never post or reply to Alan"), "{head}");
1147 let start = super::super::session_start_for(Some("seat-two"));
1148 assert!(start.starts_with("User rules"), "{start}");
1149 let at = start.find("never post or reply to Alan").unwrap();
1150 let on = start.find("ljos is on").unwrap();
1151 assert!(at < on, "{start}");
1152 let call = super::super::HookCall {
1153 event: "UserPromptSubmit".into(),
1154 cue: "open an easybuild pull request".into(),
1155 session: Some("seat-two".into()),
1156 shape: super::super::HookShape::Asks,
1157 };
1158 let (note, ids) = super::super::hook_note(&call, 5);
1159 assert!(note.starts_with("User rules"), "{note}");
1160 assert!(note.contains("never post or reply to Alan"), "{note}");
1161 assert!(ids.iter().all(|id| !id.starts_with("ur-")), "{ids:?}");
1162 let claude = super::super::hook_call(
1163 r#"{"hook_event_name":"UserPromptSubmit","session_id":"claude-1","prompt":"open an easybuild pull request"}"#,
1164 );
1165 let (claude_note, _) = super::super::hook_note(&claude, 5);
1166 let claude_out = super::super::hook_output(&claude, &claude_note);
1167 assert!(claude_out.contains("additionalContext"), "{claude_out}");
1168 assert!(
1169 claude_out.contains("never post or reply to Alan"),
1170 "{claude_out}"
1171 );
1172 let agy = super::super::hook_call_as(
1173 r#"{"conversationId":"agy-1","invocationNum":1,"prompt":"ship the easybuild change"}"#,
1174 Some("PreInvocation"),
1175 );
1176 assert_eq!(agy.event, "UserPromptSubmit");
1177 assert_eq!(agy.shape, super::super::HookShape::Steps);
1178 let (agy_note, _) = super::super::hook_note(&agy, 5);
1179 let agy_out = super::super::hook_output(&agy, &agy_note);
1180 assert!(agy_out.contains("injectSteps"), "{agy_out}");
1181 assert!(agy_out.contains("never post or reply to Alan"), "{agy_out}");
1182 unsafe {
1183 std::env::set_var("XDG_CONFIG_HOME", dir.path().join("other"));
1184 }
1185 assert!(preface().is_empty(), "a second config home has no rules");
1186 }
1187
1188 #[test]
1189 fn a_user_rule_does_not_decay_after_simulated_time() {
1190 let (_lock, _dir) = isolated();
1191 let written = alan();
1192 let later = "2099-01-01T00:00:00Z";
1193 assert_eq!(sweep(later).unwrap(), 0);
1194 assert_eq!(review_due(later), 0);
1195 assert!(!is_due(&written, later));
1196 let kept = load().unwrap();
1197 assert_eq!(kept.len(), 1);
1198 assert_eq!(kept[0].text, written.text);
1199 assert!(kept[0].due_at.is_none());
1200 let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
1201 assert!(!raw.contains("\"due_at\""), "{raw}");
1202 let mut file: File = serde_json::from_str(&raw).unwrap();
1203 file.rules.push(UserRule {
1204 id: "ur-expired".into(),
1205 text: "a row that was given a date".into(),
1206 author: "rohit".into(),
1207 reason: "fixture".into(),
1208 pattern: None,
1209 verdict: None,
1210 deny_mention: People::default(),
1211 deny_reply_to: People::default(),
1212 written_at: "2020-01-01T00:00:00Z".into(),
1213 updated_at: "2020-01-01T00:00:00Z".into(),
1214 deleted: false,
1215 from: None,
1216 due_at: Some("2020-01-02T00:00:00Z".into()),
1217 });
1218 std::fs::write(
1219 rules_path().unwrap(),
1220 serde_json::to_string_pretty(&file).unwrap(),
1221 )
1222 .unwrap();
1223 assert_eq!(sweep(later).unwrap(), 1);
1224 let kept = load().unwrap();
1225 assert_eq!(kept.len(), 1);
1226 assert_eq!(kept[0].id, written.id);
1227 assert_eq!(review_due(later), 0);
1228 }
1229
1230 #[test]
1231 fn the_preface_stays_inside_its_budget_and_keeps_the_first_rule() {
1232 let (_lock, _dir) = isolated();
1233 let first = "a".repeat(80);
1234 write_preference(&first, "first", &People::default(), &People::default()).unwrap();
1235 for n in 0..30 {
1236 let text = format!("rule {n} {}", "b".repeat(200));
1237 write_preference(&text, "later", &People::default(), &People::default()).unwrap();
1238 }
1239 let head = preface();
1240 assert!(
1241 head.chars().count() <= PREFACE_BUDGET,
1242 "{}",
1243 head.chars().count()
1244 );
1245 assert!(head.contains(&first[..40]), "{head}");
1246 assert!(head.contains("omitted"), "{head}");
1247 }
1248
1249 #[test]
1250 fn the_guard_denies_a_mention_and_a_reply_and_allows_another_comment() {
1251 let (_lock, _dir) = isolated();
1252 write_preference(
1253 "never post or reply to Alan (ocaisa)",
1254 "he asked not to be pinged",
1255 &People::default(),
1256 &People::default(),
1257 )
1258 .unwrap();
1259 let mention = "gh pr comment 12 --body 'please ask @ocaisa'";
1260 assert!(
1261 super::super::static_guard(mention).is_none(),
1262 "prose alone is not a deny"
1263 );
1264 alan();
1265 let denied = super::super::static_guard(mention).expect("mention");
1266 assert_eq!(denied.verdict, "deny");
1267 assert!(denied.pattern.starts_with("user-rule:"), "{denied:?}");
1268 assert!(
1269 denied.reason.contains("ocaisa") || denied.reason.contains("Alan"),
1270 "{denied:?}"
1271 );
1272 let reply = "gh api --method POST repos/org/repo/issues/comments/7/replies -f body=seen -F in_reply_to_user=ocaisa";
1273 let denied = super::super::static_guard(reply).expect("reply");
1274 assert_eq!(denied.verdict, "deny");
1275 let allowed = "gh pr comment 12 --body 'tests passed'";
1276 assert!(
1277 super::super::static_guard(allowed).is_none(),
1278 "unrelated comment"
1279 );
1280 assert!(super::super::static_guard("gh pr comment 12 --body 'see ocaisa'").is_none());
1281 assert!(
1282 super::super::static_guard("gh pr comment 12 --body 'please ask @ocaisa2'").is_none()
1283 );
1284 assert!(
1285 super::super::static_guard("gh pr comment 12 --body 'alanine is a molecule'").is_none()
1286 );
1287 let named =
1288 super::super::static_guard("gh pr comment 12 --body 'ask Alan to look'").expect("name");
1289 assert_eq!(named.verdict, "deny");
1290 assert!(super::super::static_guard("gh api user/ocaisa").is_none());
1291 assert!(super::super::static_guard("echo please ask @ocaisa").is_none());
1292 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"}}"#;
1293 assert!(guard_payload(mcp).is_some());
1294 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"}}"#;
1295 assert!(guard_payload(mcp_reply).is_some());
1296 let mcp_ok = r#"{"tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"tests passed"}}"#;
1297 assert!(guard_payload(mcp_ok).is_none());
1298 let mcp_read = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"body":"@ocaisa"}}"#;
1299 assert!(guard_payload(mcp_read).is_none());
1300 let cursor = super::super::hook_call(
1301 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"}}"#,
1302 );
1303 let verdict = guard_payload(
1304 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"}}"#,
1305 )
1306 .unwrap();
1307 let out = super::super::hook_output_ruled(&cursor, "", Some(&verdict));
1308 assert!(out.contains("\"permission\":\"deny\""), "{out}");
1309 }
1310
1311 #[test]
1312 fn promote_copies_a_lesson_and_refuses_anything_else() {
1313 let (_lock, _dir) = isolated();
1314 let atom = serde_json::json!({
1315 "id": "lesson-ocaisa",
1316 "kind": "lesson",
1317 "text": "Never post or reply to Alan (ocaisa).",
1318 "due_at": "2026-10-18T00:00:00Z"
1319 });
1320 let rule = promote_atom(
1321 &atom,
1322 "Alan asked that this hold for every seat",
1323 &People {
1324 handles: vec!["ocaisa".into()],
1325 names: vec!["Alan".into()],
1326 },
1327 &People {
1328 handles: vec!["ocaisa".into()],
1329 names: vec![],
1330 },
1331 )
1332 .unwrap();
1333 assert_eq!(rule.text, "Never post or reply to Alan (ocaisa).");
1334 assert_eq!(rule.author, "rohit");
1335 assert_eq!(rule.from.as_deref(), Some("lesson-ocaisa"));
1336 assert!(rule.due_at.is_none());
1337 assert_eq!(rule.deny_mention.handles, ["ocaisa"]);
1338 let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
1339 assert!(!raw.contains("\"due_at\""), "{raw}");
1340 let preference = serde_json::json!({"id": "pref-1", "kind": "preference", "text": "no"});
1341 let err = promote_atom(
1342 &preference,
1343 "because",
1344 &People::default(),
1345 &People::default(),
1346 )
1347 .unwrap_err()
1348 .to_string();
1349 assert!(err.contains("only a lesson"), "{err}");
1350 let err = promote_atom(&atom, " ", &People::default(), &People::default())
1351 .unwrap_err()
1352 .to_string();
1353 assert!(err.contains("reason"), "{err}");
1354 }
1355
1356 #[test]
1357 fn an_argv_user_rule_denies_in_the_static_guard() {
1358 let (_lock, _dir) = isolated();
1359 write_argv(&Rule {
1360 pattern: "gh pr comment*".into(),
1361 verdict: "deny".into(),
1362 reason: "no pull request comments from this seat".into(),
1363 })
1364 .unwrap();
1365 let denied = super::super::static_guard("gh pr comment 12 --body 'tests passed'").unwrap();
1366 assert_eq!(denied.verdict, "deny");
1367 assert_eq!(denied.pattern, "gh pr comment*");
1368 assert!(super::super::static_guard("gh issue list").is_none());
1369 }
1370
1371 fn git_ok(dir: &std::path::Path, args: &[&str]) {
1372 let out = std::process::Command::new("git")
1373 .arg("-C")
1374 .arg(dir)
1375 .args(args)
1376 .output()
1377 .unwrap();
1378 assert!(
1379 out.status.success(),
1380 "git {args:?} in {}: {}{}",
1381 dir.display(),
1382 String::from_utf8_lossy(&out.stderr),
1383 String::from_utf8_lossy(&out.stdout)
1384 );
1385 }
1386
1387 fn prepare_clone(dir: &std::path::Path) {
1388 git_ok(dir, &["config", "user.email", "seat@example.invalid"]);
1389 git_ok(dir, &["config", "user.name", "seat"]);
1390 git_ok(dir, &["config", "commit.gpgsign", "false"]);
1391 git_ok(dir, &["config", "core.hooksPath", "/dev/null"]);
1392 }
1393
1394 struct Homes {
1395 _lock: super::super::EnvLock,
1396 _root: tempfile::TempDir,
1397 a: std::path::PathBuf,
1398 b: std::path::PathBuf,
1399 cfg_a: std::path::PathBuf,
1400 cfg_b: std::path::PathBuf,
1401 }
1402
1403 fn age_identity(cfg: &std::path::Path) -> String {
1406 let dir = cfg.join("ljos");
1407 std::fs::create_dir_all(&dir).unwrap();
1408 let key = dir.join("age.key");
1409 let made = std::process::Command::new("age-keygen")
1410 .arg("-o")
1411 .arg(&key)
1412 .output()
1413 .unwrap();
1414 assert!(made.status.success(), "age-keygen must be installed");
1415 let public = std::process::Command::new("age-keygen")
1416 .arg("-y")
1417 .arg(&key)
1418 .output()
1419 .unwrap();
1420 String::from_utf8_lossy(&public.stdout).trim().to_string()
1421 }
1422
1423 struct EnvVar(&'static str, Option<std::ffi::OsString>);
1425
1426 impl EnvVar {
1427 fn set(name: &'static str, value: &str) -> Self {
1428 let before = std::env::var_os(name);
1429 unsafe { std::env::set_var(name, value) };
1430 Self(name, before)
1431 }
1432 }
1433
1434 impl Drop for EnvVar {
1435 fn drop(&mut self) {
1436 match self.1.take() {
1437 Some(value) => unsafe { std::env::set_var(self.0, value) },
1438 None => unsafe { std::env::remove_var(self.0) },
1439 }
1440 }
1441 }
1442
1443 fn remote_paths(bare: &std::path::Path) -> String {
1444 let out = std::process::Command::new("git")
1445 .arg("-C")
1446 .arg(bare)
1447 .args(["ls-tree", "-r", "--name-only", "HEAD"])
1448 .output()
1449 .unwrap();
1450 String::from_utf8_lossy(&out.stdout).into_owned()
1451 }
1452
1453 fn head(dir: &std::path::Path) -> String {
1454 let out = std::process::Command::new("git")
1455 .arg("-C")
1456 .arg(dir)
1457 .args(["rev-parse", "HEAD"])
1458 .output()
1459 .unwrap();
1460 String::from_utf8_lossy(&out.stdout).trim().to_string()
1461 }
1462
1463 impl Homes {
1464 fn new() -> Self {
1467 Self::with_scope(true)
1468 }
1469
1470 fn unscoped() -> Self {
1472 Self::with_scope(false)
1473 }
1474
1475 fn bare(&self) -> std::path::PathBuf {
1476 self.a.parent().unwrap().join("bare.git")
1477 }
1478
1479 fn with_scope(scoped: bool) -> Self {
1480 let lock = super::super::test_env_lock();
1481 let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1482 .join("../../target/user-rule-homes");
1483 std::fs::create_dir_all(&base).unwrap();
1484 let root = tempfile::TempDir::new_in(&base).unwrap();
1485 let seed = root.path().join("seed");
1486 std::fs::create_dir_all(&seed).unwrap();
1487 git_ok(&seed, &["init", "-q", "-b", "main"]);
1488 prepare_clone(&seed);
1489 git_ok(&seed, &["commit", "--allow-empty", "-q", "-m", "init"]);
1490 let cfg_a = root.path().join("cfg-a");
1491 let cfg_b = root.path().join("cfg-b");
1492 if scoped {
1493 let a = age_identity(&cfg_a);
1494 let b = age_identity(&cfg_b);
1495 std::fs::create_dir_all(seed.join(".ljos")).unwrap();
1496 std::fs::write(
1497 seed.join(".ljos/sync.toml"),
1498 format!("name = \"home\"\nrecipients = [\"{a}\", \"{b}\"]\n"),
1499 )
1500 .unwrap();
1501 git_ok(&seed, &["add", ".ljos/sync.toml"]);
1502 git_ok(&seed, &["commit", "-q", "-m", "scope"]);
1503 }
1504 let bare = root.path().join("bare.git");
1505 let laptop = root.path().join("laptop");
1506 let shared = root.path().join("shared");
1507 git_ok(
1508 root.path(),
1509 &[
1510 "clone",
1511 "--bare",
1512 "-q",
1513 seed.to_str().unwrap(),
1514 bare.to_str().unwrap(),
1515 ],
1516 );
1517 git_ok(
1518 root.path(),
1519 &[
1520 "clone",
1521 "-q",
1522 bare.to_str().unwrap(),
1523 laptop.to_str().unwrap(),
1524 ],
1525 );
1526 git_ok(
1527 root.path(),
1528 &[
1529 "clone",
1530 "-q",
1531 bare.to_str().unwrap(),
1532 shared.to_str().unwrap(),
1533 ],
1534 );
1535 prepare_clone(&laptop);
1536 prepare_clone(&shared);
1537 unsafe {
1538 std::env::set_var("LJOS_USER_RULES_SYNC", "off");
1539 std::env::set_var("LJOS_HOLD", "rohit");
1540 std::env::set_var("PACKSET_URL", "off");
1541 std::env::set_var("HOME", root.path());
1542 std::env::remove_var("LJOS_USER_RULES_TRACKER");
1543 std::env::remove_var("LJOS_TRACKER_GIT");
1544 std::env::set_var("XDG_STATE_HOME", root.path().join("state"));
1545 }
1546 Self {
1547 _lock: lock,
1548 _root: root,
1549 a: laptop,
1550 b: shared,
1551 cfg_a,
1552 cfg_b,
1553 }
1554 }
1555
1556 fn home(&self, cfg: &std::path::Path) {
1557 unsafe { std::env::set_var("XDG_CONFIG_HOME", cfg) };
1558 }
1559 }
1560
1561 fn exchange(tracker: &std::path::Path, host: &str) {
1562 exchange_said(tracker, host, false);
1563 }
1564
1565 fn exchange_said(tracker: &std::path::Path, host: &str, asked: bool) -> String {
1566 let said = super::super::sync::exchange_user_rules(
1567 tracker,
1568 host,
1569 std::time::Duration::from_secs(8),
1570 asked,
1571 )
1572 .unwrap_or_else(|err| panic!("sync {host}: {err:#}"));
1573 assert!(!said.contains("did not answer"), "{said}");
1574 said
1575 }
1576
1577 #[test]
1580 fn a_tracker_with_no_scope_gets_no_user_rules() {
1581 let homes = Homes::unscoped();
1582 homes.home(&homes.cfg_a);
1583 let before = head(&homes.a);
1584 let rule = write_preference(
1585 "never post or reply to Alan (ocaisa)",
1586 "he asked not to be pinged",
1587 &People::default(),
1588 &People::default(),
1589 )
1590 .unwrap();
1591 exchange(&homes.a, "laptop");
1592 let said = exchange_said(&homes.a, "laptop", true);
1593 assert!(said.contains("names no scope"), "{said}");
1594 assert_eq!(head(&homes.a), before, "no commit without a scope");
1595 assert!(!homes.a.join(".ljos/user-rules").exists());
1596 assert!(!remote_paths(&homes.bare()).contains("user-rules"));
1597 assert_eq!(load().unwrap()[0].id, rule.id);
1598 }
1599
1600 #[test]
1601 fn user_rules_travel_sealed_to_the_scope() {
1602 let homes = Homes::new();
1603 homes.home(&homes.cfg_a);
1604 write_preference(
1605 "never post or reply to Alan (ocaisa)",
1606 "he asked not to be pinged",
1607 &People::default(),
1608 &People::default(),
1609 )
1610 .unwrap();
1611 let said = exchange_said(&homes.a, "laptop", false);
1612 assert!(said.contains("sealed and pushed"), "{said}");
1613 let paths = remote_paths(&homes.bare());
1614 assert!(
1615 paths.contains(".ljos/user-rules/laptop.json.age"),
1616 "{paths}"
1617 );
1618 assert!(!paths.contains("laptop.json\n"), "{paths}");
1619 let sealed = std::fs::read(homes.a.join(".ljos/user-rules/laptop.json.age")).unwrap();
1620 assert!(
1621 !String::from_utf8_lossy(&sealed).contains("ocaisa"),
1622 "the rule left in plaintext"
1623 );
1624 let pushed = head(&homes.a);
1625 exchange(&homes.a, "laptop");
1626 assert_eq!(
1627 head(&homes.a),
1628 pushed,
1629 "unchanged rules are not sealed again"
1630 );
1631 }
1632
1633 #[test]
1636 fn a_plaintext_rules_file_is_still_read_and_this_hosts_is_replaced() {
1637 let homes = Homes::new();
1638 homes.home(&homes.cfg_b);
1639 let old = write_preference(
1640 "the older plaintext rule",
1641 "written before the seal",
1642 &People::default(),
1643 &People::default(),
1644 )
1645 .unwrap();
1646 let text = std::fs::read_to_string(homes.cfg_b.join("ljos/user-rules.json")).unwrap();
1647 let dir = homes.b.join(".ljos/user-rules");
1648 std::fs::create_dir_all(&dir).unwrap();
1649 std::fs::write(dir.join("old-host.json"), &text).unwrap();
1650 std::fs::write(dir.join("laptop.json"), &text).unwrap();
1651 git_ok(&homes.b, &["add", ".ljos/user-rules"]);
1652 git_ok(&homes.b, &["commit", "-q", "-m", "plaintext rules"]);
1653 git_ok(&homes.b, &["push", "-q"]);
1654 homes.home(&homes.cfg_a);
1655 exchange(&homes.a, "laptop");
1656 assert!(load().unwrap().iter().any(|rule| rule.id == old.id));
1657 let paths = remote_paths(&homes.bare());
1658 assert!(paths.contains(".ljos/user-rules/old-host.json"), "{paths}");
1659 assert!(
1660 paths.contains(".ljos/user-rules/laptop.json.age"),
1661 "{paths}"
1662 );
1663 assert!(!paths.contains(".ljos/user-rules/laptop.json\n"), "{paths}");
1664 }
1665
1666 #[test]
1667 fn user_rule_sync_follows_tracker_git() {
1668 let homes = Homes::new();
1669 homes.home(&homes.cfg_a);
1670 write_preference(
1671 "the standing line",
1672 "a reason",
1673 &People::default(),
1674 &People::default(),
1675 )
1676 .unwrap();
1677 let start = head(&homes.a);
1678 {
1679 let _off = EnvVar::set("LJOS_TRACKER_GIT", "off");
1680 exchange(&homes.a, "laptop");
1681 assert_eq!(head(&homes.a), start, "off leaves the tracker alone");
1682 }
1683 {
1684 let _commit = EnvVar::set("LJOS_TRACKER_GIT", "commit");
1685 let said = exchange_said(&homes.a, "laptop", false);
1686 assert!(
1687 said.contains("not pushed (LJOS_TRACKER_GIT=commit)"),
1688 "{said}"
1689 );
1690 assert_ne!(head(&homes.a), start);
1691 assert!(!remote_paths(&homes.bare()).contains("user-rules"));
1692 let said = exchange_said(&homes.a, "laptop", true);
1693 assert!(said.contains("sealed and pushed"), "{said}");
1694 }
1695 assert!(remote_paths(&homes.bare()).contains("laptop.json.age"));
1696 let _sync_off = EnvVar::set("LJOS_USER_RULES_SYNC", "off");
1697 let _tracker = EnvVar::set("LJOS_USER_RULES_TRACKER", homes.a.to_str().unwrap());
1698 assert!(super::super::sync::user_rule_trackers().is_empty());
1699 assert!(sync_now().is_err());
1700 }
1701
1702 fn rewrite(id: &str, text: &str, author: &str, reason: &str, at: &str, deleted: bool) {
1703 let mut rows = read_all().unwrap();
1704 let rule = rows.iter_mut().find(|rule| rule.id == id).unwrap();
1705 rule.text = text.to_string();
1706 rule.author = author.to_string();
1707 rule.reason = reason.to_string();
1708 rule.updated_at = at.to_string();
1709 rule.deleted = deleted;
1710 store(&rows).unwrap();
1711 }
1712
1713 #[test]
1714 fn two_homes_with_different_rules_converge() {
1715 let homes = Homes::new();
1716 homes.home(&homes.cfg_a);
1717 let alan = write_preference(
1718 "never post or reply to Alan (ocaisa)",
1719 "he asked not to be pinged",
1720 &People::default(),
1721 &People::default(),
1722 )
1723 .unwrap();
1724 exchange(&homes.a, "laptop");
1725 homes.home(&homes.cfg_b);
1726 exchange(&homes.b, "shared");
1727 let got = load().unwrap();
1728 assert_eq!(got.len(), 1, "{got:?}");
1729 assert_eq!(got[0].id, alan.id);
1730 assert_eq!(got[0].author, "rohit");
1731 assert_eq!(got[0].reason, "he asked not to be pinged");
1732 let credit = write_preference(
1733 "EasyBuild pull requests name eb-stack and gpt-oss-120b",
1734 "the credit line is part of the work",
1735 &People::default(),
1736 &People::default(),
1737 )
1738 .unwrap();
1739 assert_eq!(credit.id, load().unwrap()[1].id);
1740 exchange(&homes.b, "shared");
1741 homes.home(&homes.cfg_a);
1742 exchange(&homes.a, "laptop");
1743 let got = load().unwrap();
1744 assert!(got.iter().any(|rule| rule.id == alan.id), "{got:?}");
1745 assert!(got.iter().any(|rule| rule.id == credit.id), "{got:?}");
1746 homes.home(&homes.cfg_b);
1747 let got = load().unwrap();
1748 assert_eq!(got.len(), 2, "{got:?}");
1749 assert!(got.iter().any(|rule| rule.id == alan.id));
1750 assert!(got.iter().any(|rule| rule.id == credit.id));
1751 }
1752
1753 #[test]
1754 fn a_newer_edit_wins_and_a_tombstone_beats_an_older_edit() {
1755 let homes = Homes::new();
1756 homes.home(&homes.cfg_a);
1757 let rule = write_preference(
1758 "the standing line",
1759 "first reason",
1760 &People::default(),
1761 &People::default(),
1762 )
1763 .unwrap();
1764 let id = rule.id.clone();
1765 exchange(&homes.a, "laptop");
1766 homes.home(&homes.cfg_b);
1767 exchange(&homes.b, "shared");
1768
1769 rewrite(
1770 &id,
1771 "older edit",
1772 "ada",
1773 "because older",
1774 "2099-01-01T00:00:01.000Z",
1775 false,
1776 );
1777 exchange(&homes.b, "shared");
1778 homes.home(&homes.cfg_a);
1779 rewrite(
1780 &id,
1781 "newest edit",
1782 "rohit",
1783 "because newest",
1784 "2099-01-01T00:00:09.000Z",
1785 false,
1786 );
1787 exchange(&homes.a, "laptop");
1788 homes.home(&homes.cfg_b);
1789 exchange(&homes.b, "shared");
1790 let got = load().unwrap();
1791 assert_eq!(got.len(), 1, "{got:?}");
1792 assert_eq!(got[0].id, id);
1793 assert_eq!(got[0].text, "newest edit");
1794 assert_eq!(got[0].author, "rohit");
1795 assert_eq!(got[0].reason, "because newest");
1796 homes.home(&homes.cfg_a);
1797 let got = load().unwrap();
1798 assert_eq!(got[0].text, "newest edit");
1799 assert_eq!(got[0].author, "rohit");
1800 assert_eq!(got[0].reason, "because newest");
1801
1802 let removed = forget(&id).unwrap();
1803 assert!(removed.deleted);
1804 assert_eq!(removed.author, "rohit");
1805 assert_eq!(removed.reason, "because newest");
1806 rewrite(
1807 &id,
1808 "newest edit",
1809 "rohit",
1810 "because newest",
1811 "2099-01-02T00:00:00.000Z",
1812 true,
1813 );
1814 exchange(&homes.a, "laptop");
1815 homes.home(&homes.cfg_b);
1816 exchange(&homes.b, "shared");
1817 assert!(load().unwrap().is_empty(), "{:?}", load().unwrap());
1818 let tomb = read_all().unwrap();
1819 assert_eq!(tomb.len(), 1);
1820 assert!(tomb[0].deleted);
1821 assert_eq!(tomb[0].author, "rohit");
1822 assert_eq!(tomb[0].reason, "because newest");
1823 assert!(!preface().contains("newest edit"));
1824 rewrite(
1825 &id,
1826 "older edit",
1827 "ada",
1828 "because older",
1829 "2099-01-01T00:00:01.000Z",
1830 false,
1831 );
1832 exchange(&homes.b, "shared");
1833 assert!(
1834 load().unwrap().is_empty(),
1835 "a tombstone keeps the older edit out"
1836 );
1837 let tomb = read_all().unwrap();
1838 assert!(tomb[0].deleted);
1839 assert_eq!(tomb[0].updated_at, "2099-01-02T00:00:00.000Z");
1840 }
1841
1842 #[test]
1843 fn the_hook_shows_local_rules_when_the_tracker_does_not_answer() {
1844 let (_lock, dir) = isolated();
1845 write_preference(
1846 "never post or reply to Alan (ocaisa)",
1847 "he asked not to be pinged",
1848 &People::default(),
1849 &People::default(),
1850 )
1851 .unwrap();
1852 let repo = dir.path().join("tracker");
1853 std::fs::create_dir_all(&repo).unwrap();
1854 git_ok(&repo, &["init", "-q", "-b", "main"]);
1855 prepare_clone(&repo);
1856 git_ok(&repo, &["commit", "--allow-empty", "-q", "-m", "init"]);
1857 git_ok(&repo, &["config", "http.lowSpeedLimit", "1"]);
1858 git_ok(&repo, &["config", "http.lowSpeedTime", "1"]);
1859 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1860 let port = listener.local_addr().unwrap().port();
1861 std::thread::spawn(move || {
1862 let Ok((mut stream, _)) = listener.accept() else {
1863 return;
1864 };
1865 let mut buf = [0u8; 64];
1866 let _ = std::io::Read::read(&mut stream, &mut buf);
1867 });
1868 let url = format!("http://127.0.0.1:{port}/tracker.git");
1869 git_ok(&repo, &["remote", "add", "origin", &url]);
1870 std::fs::create_dir_all(repo.join(".ljos")).unwrap();
1872 std::fs::write(
1873 repo.join(".ljos/sync.toml"),
1874 "name = \"home\"\nrecipients = []\n",
1875 )
1876 .unwrap();
1877 unsafe {
1878 std::env::set_var("LJOS_USER_RULES_SYNC", "on");
1880 std::env::set_var("LJOS_USER_RULES_TRACKER", &repo);
1881 std::env::set_var("LJOS_USER_RULES_SYNC_MS", "400");
1882 }
1883 let started = std::time::Instant::now();
1884 let line = super::super::session_start_for(Some("seat-two"));
1885 let elapsed = started.elapsed();
1886 assert!(
1887 elapsed < std::time::Duration::from_secs(3),
1888 "{elapsed:?}\n{line}"
1889 );
1890 assert!(line.starts_with("User rules"), "{line}");
1891 assert!(line.contains("never post or reply to Alan"), "{line}");
1892 }
1893}