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, false);
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, false);
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(
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/// The rule as one line a person reads after writing it.
512#[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/// The rule as the object an MCP tool returns.
522#[must_use]
523pub fn to_value(rule: &UserRule) -> Value {
524    serde_json::to_value(rule).unwrap_or(Value::Null)
525}
526
527/// Text lines for `ljos rules`, empty when there are none.
528#[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/// The preface injected ahead of seat memory. Empty when there are no rules.
563#[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/// `body`, with the user-rule preface in front. An empty preface leaves
614/// `body` as it is.
615#[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/// `text` with one leading preface removed, so a session line and a prompt
628/// note that both carry it are not pasted twice.
629#[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/// Whether `rule` is an outbound-post denial, the only verdict
665/// `beforeMCPExecution` enforces.
666#[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/// The user-rule verdict for a shell line, if one fires.
679#[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/// The user-rule verdict for a hook payload that names a tool, if the tool
696/// is a GitHub comment, review, issue or pull-request write.
697#[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        // A home under `/tmp` suspends the guard. The scratch directory
1097        // sits beside the build output so these rules are actually judged.
1098        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    /// An age identity at `cfg/ljos/age.key`, where `identity_path` reads
1404    /// it under that config home. Returns the public key.
1405    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    /// Sets one variable until dropped, then puts back what was there.
1424    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        /// Two clones of one tracker whose `.ljos/sync.toml` seals to both
1465        /// homes' identities.
1466        fn new() -> Self {
1467            Self::with_scope(true)
1468        }
1469
1470        /// Two clones of one tracker that names no scope.
1471        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    /// A tracker with a remote and no `.ljos/sync.toml` used to receive
1578    /// every rule in plaintext on the first write.
1579    #[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    /// Plaintext files from before the seal are still read, and this
1634    /// machine's own is removed from the tracker when it seals.
1635    #[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        // A scope, so the sync reaches the fetch that does not answer.
1871        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            // Unset is off under `cargo test`, so this test asks for a sync.
1879            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}