Skip to main content

ljos_cli/
user_rules.rs

1//! Standing rules shared by every seat of this user, and by every machine
2//! that shares the tracker repository.
3//!
4//! `ljos prefer --user` and `ljos rule --user` write them. The copy the
5//! hooks read is the user config, not the pack, so the review clock never
6//! sees them. The tracker carries one file per machine, and a sync merges
7//! those files by id.
8
9use std::path::PathBuf;
10
11use anyhow::{bail, Context, Result};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use super::Rule;
16
17/// How much of the preface a brief or a hook will carry.
18pub const PREFACE_BUDGET: usize = 1200;
19
20/// Handles and names a guard matches. The prose of the rule is not read.
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22pub struct People {
23    /// GitHub logins, without a leading `@`, compared case-insensitively.
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub handles: Vec<String>,
26    /// Display names, matched as whole words.
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub names: Vec<String>,
29}
30
31/// One standing rule. `due_at` is never written. A sweep drops a row only
32/// when that field is present and already past, which a row this module
33/// writes is not.
34#[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    /// When this row last changed. A merge keeps the newer stamp, and with
50    /// it that row's author and reason.
51    #[serde(default, skip_serializing_if = "String::is_empty")]
52    pub updated_at: String,
53    /// A deletion. The row stays so an older copy of the same id does not
54    /// come back on the next sync.
55    #[serde(default, skip_serializing_if = "is_false")]
56    pub deleted: bool,
57    /// The lesson id a promote copied, when this row came from one.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub from: Option<String>,
60    /// Absent on every row the commands write. Present only so a sweep can
61    /// drop a row that was given a review date by hand.
62    #[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/// `$XDG_CONFIG_HOME/ljos/user-rules.json`, else `~/.config/ljos/user-rules.json`.
81#[must_use]
82pub fn rules_path() -> Option<PathBuf> {
83    Some(super::config_base()?.join("ljos").join("user-rules.json"))
84}
85
86/// The rows on disk that are still in force. An absent file is an empty
87/// list. Tombstones are left out.
88///
89/// # Errors
90///
91/// The file is not readable, or it is not the JSON this module writes.
92pub fn load() -> Result<Vec<UserRule>> {
93    Ok(read_all()?
94        .into_iter()
95        .filter(|rule| !rule.deleted)
96        .collect())
97}
98
99/// Every row on disk, tombstones included.
100///
101/// # Errors
102///
103/// The file is not readable, or it is not the JSON this module writes.
104pub(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
117/// Parse one user-rules document. An empty body is an empty list.
118///
119/// # Errors
120///
121/// The text is not the JSON this module writes.
122pub(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
130/// The document this module writes, tombstones included.
131///
132/// # Errors
133///
134/// The rows cannot be encoded.
135pub(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
156/// Replace the local file. Used by a sync, which must not start another sync.
157pub(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/// Whether a row would be due at `now`. A row with no `due_at` is never due.
170#[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/// How many rows would be due at `now`. Ordinary rows contribute nothing.
178#[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
187/// Drop rows whose `due_at` is already past `now`, and leave every other
188/// row where it is. Returns how many were dropped.
189///
190/// # Errors
191///
192/// The file cannot be read or rewritten.
193pub 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/// Merge rows by id. The newer `updated_at` wins, and that row's author
248/// and reason come with it. A tombstone beats an older edit of the same
249/// id, and it also beats a live row that carries the same stamp.
250#[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
338/// Write one prose rule. It is not also posted to the seat pack.
339///
340/// # Errors
341///
342/// The text or the reason is empty, a handle is not a login, or the file
343/// cannot be written.
344pub 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
356/// Write one argv law into the user file, under the same checks as a seat rule.
357///
358/// # Errors
359///
360/// [`super::validate_rule`] refuses the pattern, or the file cannot be written.
361pub 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
369/// Copy one lesson atom into a user rule. The atom is not modified.
370///
371/// # Errors
372///
373/// The atom is not a lesson, its text or the reason is empty, or the file
374/// cannot be written.
375pub 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
402/// Tombstone `id`. The author and the reason stay, so a later sync can say
403/// who removed it and why the rule stood. The lesson, if this row was
404/// promoted, is not touched.
405///
406/// # Errors
407///
408/// No such row, or the file cannot be written.
409pub 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
429/// Read lesson `id` from this seat's pack and copy it into a user rule.
430/// The lesson stays in the pack.
431///
432/// # Errors
433///
434/// The pack does not answer, no such lesson, or [`promote_atom`] refuses it.
435pub 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
471/// Pull and push before a hook builds its line. A tracker that does not
472/// answer is ignored, and the local file is what the hook shows.
473pub 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
481/// One pull and push, as `ljos prefer --user --sync` runs it.
482///
483/// # Errors
484///
485/// Sync is switched off, no tracker is configured, or the tracker did not
486/// answer inside the budget.
487pub 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/// The rule as one line a person reads after writing it.
510#[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/// The rule as the object an MCP tool returns.
520#[must_use]
521pub fn to_value(rule: &UserRule) -> Value {
522    serde_json::to_value(rule).unwrap_or(Value::Null)
523}
524
525/// Text lines for `ljos rules`, empty when there are none.
526#[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/// The preface injected ahead of seat memory. Empty when there are no rules.
561#[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/// `body`, with the user-rule preface in front. An empty preface leaves
612/// `body` as it is.
613#[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/// `text` with one leading preface removed, so a session line and a prompt
626/// note that both carry it are not pasted twice.
627#[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/// Whether `rule` is an outbound-post denial, the only verdict
663/// `beforeMCPExecution` enforces.
664#[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/// The user-rule verdict for a shell line, if one fires.
677#[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/// The user-rule verdict for a hook payload that names a tool, if the tool
694/// is a GitHub comment, review, issue or pull-request write.
695#[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        // A home under `/tmp` suspends the guard. The scratch directory
1095        // sits beside the build output so these rules are actually judged.
1096        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}