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//!
9//! A row written before `basis` was kept has neither `basis` nor `from`.
10//! It stays in the preface, marked not confirmed, until the person runs
11//! `ljos prefer --user --review --keep`. The cutoff is a local receipt
12//! written once by the first person-side command. A row written after
13//! that receipt with no basis stays out. A hook does not write the
14//! receipt: it runs under the runner.
15
16use std::io::Write;
17use std::path::PathBuf;
18
19use anyhow::{bail, Context, Result};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23use super::Rule;
24
25/// How much of the preface a brief or a hook will carry.
26pub const PREFACE_BUDGET: usize = 1200;
27
28/// Handles and names a guard matches. The prose of the rule is not read.
29#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30pub struct People {
31    /// GitHub logins, without a leading `@`, compared case-insensitively.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub handles: Vec<String>,
34    /// Display names, matched as whole words.
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub names: Vec<String>,
37}
38
39/// One standing rule. `due_at` is never written. A sweep drops a row only
40/// when that field is present and already past, which a row this module
41/// writes is not.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct UserRule {
44    pub id: String,
45    pub text: String,
46    pub author: String,
47    pub reason: String,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub pattern: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub verdict: Option<String>,
52    #[serde(default, skip_serializing_if = "people_empty")]
53    pub deny_mention: People,
54    #[serde(default, skip_serializing_if = "people_empty")]
55    pub deny_reply_to: People,
56    pub written_at: String,
57    /// When this row last changed. A merge keeps the newer stamp, and with
58    /// it that row's author and reason.
59    #[serde(default, skip_serializing_if = "String::is_empty")]
60    pub updated_at: String,
61    /// A deletion. The row stays so an older copy of the same id does not
62    /// come back on the next sync.
63    #[serde(default, skip_serializing_if = "is_false")]
64    pub deleted: bool,
65    /// The lesson id a promote copied, when this row came from one.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub from: Option<String>,
68    /// Who stands behind a rule written as words or an argv law, as
69    /// `source.basis` on a seat atom: `person` only when the person typed
70    /// it or said it in their latest prompt. A row with neither this nor
71    /// `from` was written before that stamp. It stays visible, marked
72    /// held, when it is older than the cutoff receipt.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub basis: Option<String>,
75    /// [`lesson_hash`] of that lesson's record as the promote read it. A
76    /// record under `from` that hashes otherwise is another lesson.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub lesson_hash: Option<String>,
79    /// Absent on every row the commands write. Present only so a sweep can
80    /// drop a row that was given a review date by hand.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub due_at: Option<String>,
83}
84
85fn people_empty(p: &People) -> bool {
86    p.handles.is_empty() && p.names.is_empty()
87}
88
89fn is_false(value: &bool) -> bool {
90    !*value
91}
92
93#[derive(Debug, Default, Serialize, Deserialize)]
94struct File {
95    #[serde(default)]
96    rules: Vec<UserRule>,
97}
98
99/// `$XDG_CONFIG_HOME/ljos/user-rules.json`, else `~/.config/ljos/user-rules.json`.
100#[must_use]
101pub fn rules_path() -> Option<PathBuf> {
102    Some(super::config_base()?.join("ljos").join("user-rules.json"))
103}
104
105/// The most a user-rules file may hold. A larger file is not read.
106pub const RULES_FILE_CAP: u64 = 1024 * 1024;
107
108/// The rows on disk that are still in force. An absent file is an empty
109/// list. Tombstones are left out. A file caught empty or half-written
110/// is the last copy that parsed, so a rewrite does not drop the rules.
111///
112/// # Errors
113///
114/// A dangling symlink, not a regular file of at most [`RULES_FILE_CAP`]
115/// bytes, or not readable.
116pub fn load() -> Result<Vec<UserRule>> {
117    Ok(read_all()?
118        .into_iter()
119        .filter(|rule| !rule.deleted)
120        .collect())
121}
122
123/// Every row on disk, tombstones included.
124///
125/// # Errors
126///
127/// A dangling symlink, not a regular file of at most [`RULES_FILE_CAP`]
128/// bytes, or not readable. An empty file or one that does not parse is
129/// a rewrite in progress: the last whole copy is returned. A file that
130/// has never parsed is an error.
131pub(crate) fn read_all() -> Result<Vec<UserRule>> {
132    let Some(path) = rules_path() else {
133        return Ok(Vec::new());
134    };
135    // Empty text, whitespace, and JSON that does not parse can be a
136    // rewrite caught between a truncate and the new bytes. A dangling
137    // symlink, a FIFO, and a file over the cap are the file they are,
138    // and those errors return at once.
139    // The stamp is the length, the mtime, and a prefix of the bytes.
140    // A stamp that stays the same for `RULES_STABLE`, or that keeps
141    // moving until `RULES_HARD`, is a file that is not whole yet. The
142    // last copy that parsed stands until this one does. That read is
143    // not a refused write. With no such copy, the error stands.
144    let started = std::time::Instant::now();
145    let mut stable_since = std::time::Instant::now();
146    let mut last_stamp: Option<RulesStamp> = None;
147    loop {
148        match read_rules_text(&path) {
149            Ok(None) => return Ok(Vec::new()),
150            Ok(Some(text)) => match parse_rules(&text) {
151                Ok(rules) => {
152                    remember_good(&path, &text);
153                    return Ok(rules);
154                }
155                Err(err) => {
156                    if rules_read_gave_up(
157                        &path,
158                        Some(text.as_bytes()),
159                        &mut last_stamp,
160                        &mut stable_since,
161                        started,
162                    ) {
163                        return held_or(
164                            &path,
165                            err.context(format!(
166                                "user rules: {} is not a user-rules file",
167                                path.display()
168                            )),
169                        );
170                    }
171                }
172            },
173            Err(err) if rules_rewrite_symptom(&err) => {
174                if rules_read_gave_up(&path, None, &mut last_stamp, &mut stable_since, started) {
175                    return held_or(&path, err);
176                }
177            }
178            Err(err) => return Err(err),
179        }
180        std::thread::sleep(std::time::Duration::from_millis(1));
181    }
182}
183
184/// `user-rules.json.good`: the last text [`read_all`] parsed.
185fn good_copy(path: &std::path::Path) -> std::path::PathBuf {
186    let mut name = path.file_name().unwrap_or_default().to_os_string();
187    name.push(".good");
188    path.with_file_name(name)
189}
190
191fn remember_good(path: &std::path::Path, text: &str) {
192    let good = good_copy(path);
193    if std::fs::read(&good).is_ok_and(|have| have == text.as_bytes()) {
194        return;
195    }
196    let Some(parent) = path.parent() else {
197        return;
198    };
199    let Ok(tmp) = unique_rules_temp(parent) else {
200        return;
201    };
202    if std::fs::write(&tmp, text).is_err() {
203        let _ = std::fs::remove_file(&tmp);
204        return;
205    }
206    if std::fs::rename(&tmp, &good).is_err() {
207        let _ = std::fs::remove_file(&tmp);
208    }
209}
210
211/// The last whole document when this seat has read one. Otherwise `err`,
212/// which is what a file that was never valid still returns.
213fn held_or(path: &std::path::Path, err: anyhow::Error) -> Result<Vec<UserRule>> {
214    let Ok(text) = std::fs::read_to_string(good_copy(path)) else {
215        return Err(err);
216    };
217    parse_rules(&text).map_err(|_| err)
218}
219
220/// How long an unchanged bad file is treated as corrupt. A rewrite is
221/// empty between its truncate and its write, and on a busy machine the
222/// writer can be set aside for longer than a few milliseconds there, so
223/// the window is wide enough that a paused rewrite is not read as a
224/// corrupt file. A file that is truly empty costs this much once.
225const RULES_STABLE: std::time::Duration = std::time::Duration::from_millis(250);
226/// How long a file whose bytes keep changing is re-read. Well inside the
227/// hook's own five-second budget.
228const RULES_HARD: std::time::Duration = std::time::Duration::from_millis(600);
229
230#[derive(PartialEq, Eq)]
231struct RulesStamp {
232    len: u64,
233    mtime: Option<std::time::SystemTime>,
234    prefix: [u8; 64],
235    prefix_len: usize,
236}
237
238fn rules_rewrite_symptom(err: &anyhow::Error) -> bool {
239    format!("{err:#}").contains(" is empty")
240}
241
242fn rules_stamp(path: &std::path::Path, bytes: Option<&[u8]>) -> RulesStamp {
243    let meta = std::fs::symlink_metadata(path).ok();
244    let prefix_src = bytes.unwrap_or(b"");
245    let n = prefix_src.len().min(64);
246    let mut prefix = [0u8; 64];
247    prefix[..n].copy_from_slice(&prefix_src[..n]);
248    RulesStamp {
249        len: meta.as_ref().map(|m| m.len()).unwrap_or(0),
250        mtime: meta.as_ref().and_then(|m| m.modified().ok()),
251        prefix,
252        prefix_len: n,
253    }
254}
255
256/// `true` when this read should be the last: the stamp has not moved for
257/// [`RULES_STABLE`], or the hard cap has passed.
258fn rules_read_gave_up(
259    path: &std::path::Path,
260    bytes: Option<&[u8]>,
261    last: &mut Option<RulesStamp>,
262    stable_since: &mut std::time::Instant,
263    started: std::time::Instant,
264) -> bool {
265    let stamp = rules_stamp(path, bytes);
266    let same = match last {
267        Some(prev) if *prev == stamp => true,
268        _ => {
269            *last = Some(stamp);
270            *stable_since = std::time::Instant::now();
271            false
272        }
273    };
274    if started.elapsed() >= RULES_HARD {
275        return true;
276    }
277    same && stable_since.elapsed() >= RULES_STABLE
278}
279
280/// The text of the user-rules file. `Ok(None)` when no file is there.
281/// A zero-byte file, a file of only whitespace, and a dangling symlink
282/// are errors. The open does not block: a FIFO, a directory, or any
283/// other non-regular file is an error, and a regular file over
284/// [`RULES_FILE_CAP`] is not read.
285fn read_rules_text(path: &std::path::Path) -> Result<Option<String>> {
286    use std::io::Read;
287    use std::os::unix::fs::OpenOptionsExt;
288    let file = match std::fs::OpenOptions::new()
289        .read(true)
290        .custom_flags(libc::O_NONBLOCK)
291        .open(path)
292    {
293        Ok(file) => file,
294        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
295            return match std::fs::symlink_metadata(path) {
296                Ok(meta) if meta.file_type().is_symlink() => {
297                    bail!("user rules: {} is a dangling symlink", path.display());
298                }
299                Ok(_) => Err(anyhow::Error::from(err)
300                    .context(format!("user rules: read {} failed", path.display()))),
301                Err(stat) if stat.kind() == std::io::ErrorKind::NotFound => Ok(None),
302                Err(stat) => Err(anyhow::Error::from(stat)
303                    .context(format!("user rules: stat {} failed", path.display()))),
304            };
305        }
306        Err(err) => {
307            return Err(anyhow::Error::from(err)
308                .context(format!("user rules: read {} failed", path.display())));
309        }
310    };
311    // Stat the opened descriptor. The type and the size are this inode.
312    let meta = file
313        .metadata()
314        .with_context(|| format!("user rules: stat {} failed", path.display()))?;
315    if !meta.is_file() {
316        bail!("user rules: {} is not a regular file", path.display());
317    }
318    if meta.len() == 0 {
319        bail!("user rules: {} is empty", path.display());
320    }
321    if meta.len() > RULES_FILE_CAP {
322        bail!(
323            "user rules: {} is {} bytes, over the {} byte cap",
324            path.display(),
325            meta.len(),
326            RULES_FILE_CAP
327        );
328    }
329    let mut text = String::new();
330    file.take(RULES_FILE_CAP)
331        .read_to_string(&mut text)
332        .with_context(|| format!("user rules: read {} failed", path.display()))?;
333    if text.trim().is_empty() {
334        bail!("user rules: {} is empty", path.display());
335    }
336    Ok(Some(text))
337}
338
339/// Parse one user-rules document. An empty body is an empty list.
340///
341/// # Errors
342///
343/// The text is not the JSON this module writes.
344pub(crate) fn parse_rules(text: &str) -> Result<Vec<UserRule>> {
345    if text.trim().is_empty() {
346        return Ok(Vec::new());
347    }
348    let file: File = serde_json::from_str(text)?;
349    Ok(file.rules)
350}
351
352/// The document this module writes, tombstones included.
353///
354/// # Errors
355///
356/// The rows cannot be encoded.
357pub(crate) fn render_rules(rules: &[UserRule]) -> Result<String> {
358    let rules: Vec<UserRule> = rules.iter().cloned().map(dated).collect();
359    let body = serde_json::to_string_pretty(&File { rules })?;
360    Ok(format!("{body}\n"))
361}
362
363fn store(rules: &[UserRule]) -> Result<()> {
364    let _durable = crate::bookkeep::durable_write();
365    let path = rules_path().context("user rules: no home and no XDG_CONFIG_HOME")?;
366    let Some(parent) = path.parent() else {
367        anyhow::bail!("user rules: {} has no directory", path.display());
368    };
369    std::fs::create_dir_all(parent)
370        .with_context(|| format!("user rules: create {} failed", parent.display()))?;
371    let body = render_rules(rules)?;
372    if std::fs::read(&path).is_ok_and(|have| have == body.as_bytes()) {
373        return Ok(());
374    }
375    // A unique temp in the same directory, fsync, then rename. Two stores
376    // no longer share `user-rules.json.tmp`, and a reader never sees a
377    // truncated document from this write.
378    let tmp = unique_rules_temp(parent)?;
379    let write_tmp = (|| -> Result<()> {
380        let mut file = std::fs::OpenOptions::new()
381            .write(true)
382            .create_new(true)
383            .open(&tmp)
384            .with_context(|| format!("user rules: create {} failed", tmp.display()))?;
385        file.write_all(body.as_bytes())
386            .with_context(|| format!("user rules: write {} failed", tmp.display()))?;
387        file.sync_all()
388            .with_context(|| format!("user rules: sync {} failed", tmp.display()))?;
389        Ok(())
390    })();
391    if let Err(err) = write_tmp {
392        let _ = std::fs::remove_file(&tmp);
393        return Err(err);
394    }
395    if let Err(err) = std::fs::rename(&tmp, &path) {
396        let _ = std::fs::remove_file(&tmp);
397        return Err(err).context(format!("user rules: rename into {} failed", path.display()));
398    }
399    if let Ok(dir) = std::fs::File::open(parent) {
400        let _ = dir.sync_all();
401    }
402    Ok(())
403}
404
405fn unique_rules_temp(parent: &std::path::Path) -> Result<std::path::PathBuf> {
406    for n in 0..32u32 {
407        let nanos = std::time::SystemTime::now()
408            .duration_since(std::time::UNIX_EPOCH)
409            .map(|d| d.as_nanos())
410            .unwrap_or(0);
411        let path = parent.join(format!(
412            ".user-rules.{}.{nanos}.{n}.tmp",
413            std::process::id()
414        ));
415        if !path.exists() {
416            return Ok(path);
417        }
418    }
419    anyhow::bail!(
420        "user rules: could not pick a temp name in {}",
421        parent.display()
422    );
423}
424
425/// Replace the local file. Used by a sync, which must not start another sync.
426pub(crate) fn replace_stored(rules: &[UserRule]) -> Result<()> {
427    store(rules)
428}
429
430fn append(rule: UserRule) -> Result<UserRule> {
431    let mut rules = read_all()?;
432    rules.push(rule.clone());
433    store(&rules)?;
434    sync_after_write();
435    Ok(rule)
436}
437
438/// Whether a row would be due at `now`. A row with no `due_at` is never due.
439#[must_use]
440pub fn is_due(rule: &UserRule, now: &str) -> bool {
441    rule.due_at
442        .as_deref()
443        .is_some_and(|due| !due.is_empty() && due <= now)
444}
445
446/// How many rows would be due at `now`. Ordinary rows contribute nothing.
447#[must_use]
448pub fn review_due(now: &str) -> usize {
449    load()
450        .unwrap_or_default()
451        .iter()
452        .filter(|rule| is_due(rule, now))
453        .count()
454}
455
456/// Drop rows whose `due_at` is already past `now`, and leave every other
457/// row where it is. Returns how many were dropped.
458///
459/// # Errors
460///
461/// The file cannot be read or rewritten.
462pub fn sweep(now: &str) -> Result<usize> {
463    let rules = read_all()?;
464    let kept: Vec<UserRule> = rules
465        .iter()
466        .filter(|rule| !is_due(rule, now))
467        .cloned()
468        .collect();
469    let dropped = rules.len() - kept.len();
470    if dropped > 0 {
471        store(&kept)?;
472    }
473    Ok(dropped)
474}
475
476fn new_id(text: &str) -> String {
477    let mut raw = [0u8; 16];
478    if let Ok(mut file) = std::fs::File::open("/dev/urandom") {
479        use std::io::Read;
480        let _ = file.read_exact(&mut raw);
481    }
482    let tick = std::time::SystemTime::now()
483        .duration_since(std::time::UNIX_EPOCH)
484        .map(|d| d.as_nanos() as u64)
485        .unwrap_or(0);
486    for (i, byte) in raw.iter_mut().enumerate().take(8) {
487        *byte ^= (tick >> (i * 8)) as u8;
488    }
489    for (i, byte) in text.bytes().enumerate() {
490        raw[i % raw.len()] ^= byte.wrapping_add(i as u8);
491    }
492    let mut hex = String::with_capacity(32);
493    for byte in raw {
494        hex.push(b"0123456789abcdef"[(byte >> 4) as usize] as char);
495        hex.push(b"0123456789abcdef"[(byte & 0x0f) as usize] as char);
496    }
497    format!("ur-{hex}")
498}
499
500fn stamp(rule: &UserRule) -> &str {
501    let at = rule.updated_at.trim();
502    if at.is_empty() {
503        rule.written_at.trim()
504    } else {
505        at
506    }
507}
508
509fn dated(mut rule: UserRule) -> UserRule {
510    if rule.updated_at.trim().is_empty() {
511        rule.updated_at = rule.written_at.clone();
512    }
513    rule
514}
515
516/// Merge rows by id. The newer `updated_at` wins, and that row's author
517/// and reason come with it. A tombstone beats an older edit of the same
518/// id, and it also beats a live row that carries the same stamp.
519#[must_use]
520pub fn merge_rows(rows: impl IntoIterator<Item = UserRule>) -> Vec<UserRule> {
521    let mut best: std::collections::BTreeMap<String, UserRule> = std::collections::BTreeMap::new();
522    for rule in rows {
523        let id = rule.id.trim().to_string();
524        if id.is_empty() {
525            continue;
526        }
527        let rule = dated(rule);
528        if best.get(&id).is_some_and(|have| beats(have, &rule)) {
529            continue;
530        }
531        best.insert(id, rule);
532    }
533    best.into_values().collect()
534}
535
536fn beats(have: &UserRule, incoming: &UserRule) -> bool {
537    match stamp(have).cmp(stamp(incoming)) {
538        std::cmp::Ordering::Greater => true,
539        std::cmp::Ordering::Less => false,
540        std::cmp::Ordering::Equal => have.deleted && !incoming.deleted,
541    }
542}
543
544fn normalize_handle(raw: &str) -> Result<String> {
545    let handle = raw.trim().trim_start_matches('@');
546    if handle.is_empty()
547        || !handle
548            .chars()
549            .all(|c| c.is_ascii_alphanumeric() || c == '-')
550    {
551        bail!("prefer: {raw:?} is not a handle");
552    }
553    Ok(handle.to_ascii_lowercase())
554}
555
556fn clean_people(people: &People) -> Result<People> {
557    let mut handles = Vec::new();
558    for handle in &people.handles {
559        let handle = normalize_handle(handle)?;
560        if !handles.contains(&handle) {
561            handles.push(handle);
562        }
563    }
564    let mut names = Vec::new();
565    for name in &people.names {
566        let name = name.split_whitespace().collect::<Vec<_>>().join(" ");
567        if name.is_empty() {
568            bail!("prefer: a name needs letters");
569        }
570        if !names
571            .iter()
572            .any(|kept: &String| kept.eq_ignore_ascii_case(&name))
573        {
574            names.push(name);
575        }
576    }
577    Ok(People { handles, names })
578}
579
580fn fresh(text: &str, reason: &str, from: Option<String>) -> Result<UserRule> {
581    let text = text.trim();
582    let reason = reason.trim();
583    if text.is_empty() {
584        bail!("prefer: a user rule needs the words it states");
585    }
586    if reason.is_empty() {
587        bail!("prefer: a user rule needs a reason, so say why with --why");
588    }
589    let now = super::now_utc();
590    Ok(UserRule {
591        id: new_id(text),
592        text: text.to_string(),
593        author: super::holder_name(),
594        reason: reason.to_string(),
595        pattern: None,
596        verdict: None,
597        deny_mention: People::default(),
598        deny_reply_to: People::default(),
599        written_at: now.clone(),
600        updated_at: now,
601        deleted: false,
602        from,
603        basis: None,
604        lesson_hash: None,
605        due_at: None,
606    })
607}
608
609/// Write one prose rule, from any caller. It is not also posted to the
610/// seat pack. The row's `basis` is the label: the person's when they typed
611/// it or it is in the prompt the hook kept, else the agent's, which `ljos
612/// rules` lists as unverified.
613///
614/// # Errors
615///
616/// The text or the reason is empty, a handle is not a login, or the file
617/// cannot be written.
618pub fn write_preference(
619    text: &str,
620    reason: &str,
621    deny_mention: &People,
622    deny_reply_to: &People,
623) -> Result<UserRule> {
624    let mut rule = fresh(text, reason, None)?;
625    rule.basis = Some(super::Basis::of_typed(text).as_str().to_string());
626    rule.deny_mention = clean_people(deny_mention)?;
627    rule.deny_reply_to = clean_people(deny_reply_to)?;
628    append(rule)
629}
630
631/// Write one argv law into the user file, under the same checks as a seat
632/// rule, with `basis` as the caller decided it, as a seat rule takes it.
633///
634/// # Errors
635///
636/// [`super::validate_rule`] refuses the pattern, or the file cannot be written.
637pub fn write_argv(rule: &Rule, basis: super::Basis) -> Result<UserRule> {
638    super::validate_rule(rule)?;
639    if rule.verdict == "allow" && !matches!(basis, super::Basis::Person) {
640        bail!(
641            "rule: an allow is the person's word. Run `ljos rule --user {:?} --verdict allow \
642             --why ...` at your own terminal, not under a runner",
643            rule.pattern
644        );
645    }
646    let mut row = fresh(rule.reason.trim(), rule.reason.trim(), None)?;
647    row.pattern = Some(rule.pattern.trim().to_string());
648    row.verdict = Some(rule.verdict.clone());
649    row.basis = Some(basis.as_str().to_string());
650    append(row)
651}
652
653/// Copy one lesson atom into a user rule. The atom is not modified.
654///
655/// # Errors
656///
657/// The atom is not a lesson, its text or the reason is empty, or the file
658/// cannot be written.
659pub fn promote_atom(
660    atom: &Value,
661    reason: &str,
662    deny_mention: &People,
663    deny_reply_to: &People,
664) -> Result<UserRule> {
665    let kind = atom.get("kind").and_then(Value::as_str).unwrap_or("");
666    if kind != "lesson" {
667        let id = atom.get("id").and_then(Value::as_str).unwrap_or("(no id)");
668        bail!("prefer: {id} is a {kind}, and only a lesson is promoted");
669    }
670    let id = atom
671        .get("id")
672        .and_then(Value::as_str)
673        .context("prefer: the lesson has no id")?
674        .to_string();
675    let text = atom
676        .get("text")
677        .and_then(Value::as_str)
678        .unwrap_or("")
679        .trim();
680    let mut rule = fresh(text, reason, Some(id))?;
681    rule.lesson_hash = Some(lesson_hash(atom));
682    rule.deny_mention = clean_people(deny_mention)?;
683    rule.deny_reply_to = clean_people(deny_reply_to)?;
684    append(rule)
685}
686
687/// Sha256 of what makes lesson record `atom` the lesson it is: its kind,
688/// its text, its `ts` and `valid_from`, and the origin it was stored
689/// under as [`super::stored_by`] reads it. packsetd keeps the first `ts`
690/// when a tombstoned id is posted again and stamps a new `valid_from`, so
691/// a repost is another lesson even in the same words, as are an edit of
692/// the text and an origin changed in place. Links, grades and the update
693/// stamp move on writes that leave the lesson as it was, and are not read.
694#[must_use]
695pub fn lesson_hash(atom: &Value) -> String {
696    use sha2::{Digest, Sha256};
697    let field = |key: &str| atom.get(key).and_then(Value::as_str).unwrap_or("");
698    let mut hash = Sha256::new();
699    for part in [
700        field("kind"),
701        field("text"),
702        field("ts"),
703        field("valid_from"),
704        super::stored_by(atom).origin(),
705    ] {
706        hash.update(part.as_bytes());
707        hash.update([0]);
708    }
709    hex::encode(hash.finalize())
710}
711
712/// Tombstone `id`. The author and the reason stay, so a later sync can say
713/// who removed it and why the rule stood. The lesson, if this row was
714/// promoted, is not touched.
715///
716/// # Errors
717///
718/// No such row, or the file cannot be written.
719pub fn forget(id: &str) -> Result<UserRule> {
720    let id = id.trim();
721    if id.is_empty() {
722        bail!("prefer: a user rule id is required");
723    }
724    let mut rules = read_all()?;
725    let Some(rule) = rules.iter_mut().find(|rule| rule.id == id) else {
726        bail!("prefer: no user rule {id}");
727    };
728    if !rule.deleted {
729        rule.deleted = true;
730        rule.updated_at = super::now_utc();
731        let written = rule.clone();
732        store(&rules)?;
733        sync_after_write();
734        return Ok(written);
735    }
736    Ok(rule.clone())
737}
738
739/// Read lesson `id` from this seat's pack and copy it into a user rule.
740/// The lesson stays in the pack, and the rule is unverified until the pack
741/// confirms the lesson is the person's.
742///
743/// # Errors
744///
745/// The pack does not answer, no such lesson, or [`promote_atom`] refuses it.
746pub fn promote_lesson(
747    id: &str,
748    reason: &str,
749    deny_mention: &People,
750    deny_reply_to: &People,
751) -> Result<UserRule> {
752    let id = id.trim();
753    if id.is_empty() {
754        bail!("prefer: a lesson id is required");
755    }
756    let client = super::pack()?;
757    let atoms =
758        super::atoms_lean(&client, &client.workspace()).context("prefer: GET /v1/atoms failed")?;
759    let atom = atoms
760        .into_iter()
761        .find(|atom| atom.get("id").and_then(Value::as_str) == Some(id))
762        .with_context(|| format!("prefer: no atom {id} in this seat's pack"))?;
763    promote_atom(&atom, reason, deny_mention, deny_reply_to)
764}
765
766fn sync_budget_ms(default: u64) -> u64 {
767    std::env::var("LJOS_USER_RULES_SYNC_MS")
768        .ok()
769        .and_then(|value| value.trim().parse().ok())
770        .filter(|ms: &u64| *ms > 0)
771        .unwrap_or(default)
772}
773
774fn sync_after_write() {
775    let budget = std::time::Duration::from_millis(sync_budget_ms(4_000));
776    let host = super::sync::host();
777    for root in super::sync::user_rule_trackers() {
778        let _ = super::sync::exchange_user_rules(&root, &host, budget, false);
779    }
780}
781
782/// Pull and push before a hook builds its line. A tracker that does not
783/// answer is ignored, and the local file is what the hook shows.
784pub fn sync_for_hook() {
785    // The tracker exchange is not a pack read. The side budget is for a
786    // quiet pack socket. A fetch slower than that budget still has to
787    // finish, or a rule written on another machine never arrives and the
788    // pack is never asked.
789    let budget = std::time::Duration::from_millis(sync_budget_ms(1_500));
790    let host = super::sync::host();
791    for root in super::sync::user_rule_trackers() {
792        let _ = super::sync::exchange_user_rules(&root, &host, budget, false);
793    }
794}
795
796/// One pull and push, as `ljos prefer --user --sync` runs it.
797///
798/// # Errors
799///
800/// Sync is switched off, no tracker is configured, or the tracker did not
801/// answer inside the budget.
802pub fn sync_now() -> Result<String> {
803    if std::env::var("LJOS_USER_RULES_SYNC").ok().as_deref() == Some("off") {
804        bail!("prefer: user-rule sync is off");
805    }
806    let roots = super::sync::user_rule_trackers();
807    if roots.is_empty() {
808        bail!(
809            "prefer: no tracker repository to sync user rules through. Set VISSUE_ROOT, or repos in ~/.config/ljos/sync.toml"
810        );
811    }
812    let budget = std::time::Duration::from_millis(sync_budget_ms(8_000));
813    let host = super::sync::host();
814    let mut out = String::new();
815    for root in roots {
816        out.push_str(&super::sync::exchange_user_rules(
817            &root, &host, budget, true,
818        )?);
819    }
820    if out.is_empty() {
821        out.push_str("sync: user rules: none\n");
822    }
823    Ok(out)
824}
825
826/// The rule as one line a person reads after writing it.
827#[must_use]
828pub fn ack(rule: &UserRule) -> String {
829    if rule.deleted {
830        format!("user rule {}\tremoved\t{}", rule.id, rule.text)
831    } else {
832        format!("user rule {}\t{}", rule.id, rule.text)
833    }
834}
835
836/// The rule as the object an MCP tool returns.
837#[must_use]
838pub fn to_value(rule: &UserRule) -> Value {
839    serde_json::to_value(rule).unwrap_or(Value::Null)
840}
841
842/// Text lines for `ljos rules`, empty when there are none. A row held
843/// from before a basis was kept is listed apart from a promoted rule the
844/// pack does not confirm, which is unverified, with why.
845#[must_use]
846pub fn format_list() -> String {
847    let Ok(rules) = load() else {
848        return String::new();
849    };
850    let (mut persons, mut held, mut unverified) = (String::new(), String::new(), String::new());
851    for rule in &rules {
852        let law = match (&rule.verdict, &rule.pattern) {
853            (Some(verdict), Some(pattern)) => format!("{verdict}\t{pattern}\t"),
854            _ => String::new(),
855        };
856        let line = format!("{law}{}\t{}\t{}", rule.text, rule.author, rule.reason);
857        if is_held(rule) {
858            held.push_str(&format!("held\t{line}\n"));
859            continue;
860        }
861        match unconfirmed(rule) {
862            None => persons.push_str(&format!("user\t{line}\n")),
863            Some(why) => unverified.push_str(&format!("unverified\t{line}\t{why}\n")),
864        }
865    }
866    let mut out = String::new();
867    if !persons.is_empty() {
868        out.push_str("user rules (shared, never reviewed):\n");
869        out.push_str(&persons);
870    }
871    if !held.is_empty() {
872        out.push_str("user rules held from before a basis was kept:\n");
873        out.push_str(&held);
874    }
875    if !unverified.is_empty() {
876        out.push_str("user rules not confirmed as the person's (unverified):\n");
877        out.push_str(&unverified);
878    }
879    out
880}
881
882fn one_line(text: &str) -> String {
883    text.split_whitespace().collect::<Vec<_>>().join(" ")
884}
885
886fn omission(left: usize) -> String {
887    if left == 0 {
888        String::new()
889    } else {
890        format!("{left} user rules omitted.\n")
891    }
892}
893
894/// The preface injected ahead of seat memory. Empty when there are no rules
895/// the preface may show. A held row is shown in its own section, not as a
896/// rule the person confirmed.
897#[must_use]
898pub fn preface() -> String {
899    let rules = load().unwrap_or_default();
900    let standing: Vec<UserRule> = rules
901        .iter()
902        .filter(|rule| persons_rule(rule))
903        .cloned()
904        .collect();
905    let held: Vec<UserRule> = rules.iter().filter(|rule| is_held(rule)).cloned().collect();
906    render_preface(&standing, &held, PREFACE_BUDGET)
907}
908
909/// Whether the preface may state `rule` as the person's. A rule written
910/// as words or an argv law is when its basis is the person's. A rule
911/// promoted from a lesson is only when this read got
912/// that lesson from this seat's pack just now, under its id, stored as
913/// the person's, and it is the record the rule was promoted from: its
914/// [`lesson_hash`] is the one the row kept. A row with no hash kept has
915/// nothing to match. Anything less leaves the rule out of the preface,
916/// not stated as unverified: the preface is read as the person's, and a
917/// pack that is slow, down or answers for another atom confirms nothing.
918fn persons_rule(rule: &UserRule) -> bool {
919    unconfirmed(rule).is_none()
920}
921
922/// Why `rule` may not be read as the person's, or `None` when it may: the
923/// test [`persons_rule`] states.
924fn unconfirmed(rule: &UserRule) -> Option<String> {
925    let Some(id) = rule.from.as_deref() else {
926        return (rule.basis.as_deref() != Some(super::Basis::Person.as_str())).then(|| {
927            "written without the person at the terminal or in their latest prompt".to_string()
928        });
929    };
930    let Some(kept) = rule.lesson_hash.as_deref() else {
931        return Some(format!(
932            "promoted from lesson {id} with no hash of its record kept; promote it again"
933        ));
934    };
935    match super::atom_now(id) {
936        Ok(Some(lesson)) if !super::person_stored(&lesson) => Some(format!(
937            "the pack does not hold lesson {id} as the person's"
938        )),
939        Ok(Some(lesson)) if lesson_hash(&lesson) != kept => Some(format!(
940            "lesson {id} in the pack is not the record this rule was promoted from"
941        )),
942        Ok(Some(_)) => None,
943        Ok(None) => Some(format!("the pack does not hold lesson {id}")),
944        Err(why) => Some(why),
945    }
946}
947
948/// `$XDG_CONFIG_HOME/ljos/user-rule-basis-cutoff`. One RFC 3339 instant,
949/// written once. Hooks do not read the pack to decide this.
950fn cutoff_path() -> Option<PathBuf> {
951    Some(
952        super::config_base()?
953            .join("ljos")
954            .join("user-rule-basis-cutoff"),
955    )
956}
957
958fn parse_instant(raw: &str) -> Option<chrono::DateTime<chrono::FixedOffset>> {
959    let raw = raw.trim();
960    (!raw.is_empty())
961        .then(|| chrono::DateTime::parse_from_rfc3339(raw).ok())
962        .flatten()
963}
964
965fn cutoff_instant() -> Option<chrono::DateTime<chrono::FixedOffset>> {
966    let text = std::fs::read_to_string(cutoff_path()?).ok()?;
967    text.lines().find_map(parse_instant)
968}
969
970/// Record the cutoff for user-rule basis, once, from a person-side command.
971///
972/// The instant is this run's time. A preference cutoff from the earlier
973/// upgrade is not used: user rules written after that receipt still had
974/// no basis, and treating them as new would drop them. Under a runner
975/// this does nothing. A file that is already there is left as it is.
976pub fn note_cutoff() {
977    if super::under_a_runner() {
978        return;
979    }
980    let Some(path) = cutoff_path() else {
981        return;
982    };
983    if path.exists() {
984        return;
985    }
986    if let Some(dir) = path.parent() {
987        let _ = std::fs::create_dir_all(dir);
988    }
989    let _ = std::fs::write(&path, format!("{}\n", super::now_utc()));
990}
991
992/// A row from before basis was kept: no `basis`, no lesson it was promoted
993/// from, and either no cutoff yet or a `written_at` older than it. A stamp
994/// that does not parse is treated as older, so a row is not dropped because
995/// its time cannot be read. A basis of `agent`, or any other basis, is a
996/// decision already made and is not held.
997fn is_held(rule: &UserRule) -> bool {
998    rule.basis.is_none() && from_before_cutoff(rule)
999}
1000
1001/// A row written as words or an argv law before the cutoff, whatever basis
1002/// a keep has given it since.
1003fn from_before_cutoff(rule: &UserRule) -> bool {
1004    if rule.from.is_some() {
1005        return false;
1006    }
1007    match cutoff_instant() {
1008        None => true,
1009        Some(cutoff) => parse_instant(&rule.written_at).is_none_or(|ts| ts < cutoff),
1010    }
1011}
1012
1013/// How many user rules [`is_held`] counts. Session start and the doctor
1014/// report this. Neither path is required to have written the cutoff.
1015#[must_use]
1016pub fn held_count() -> usize {
1017    load()
1018        .unwrap_or_default()
1019        .iter()
1020        .filter(|rule| is_held(rule))
1021        .count()
1022}
1023
1024/// The sentence the doctor and `ljos prefer --user --review` share.
1025#[must_use]
1026pub fn review_sentence(n: usize) -> String {
1027    format!(
1028        "{n} older user rule{} await{} review (`ljos prefer --user --review`)",
1029        if n == 1 { "" } else { "s" },
1030        if n == 1 { "s" } else { "" }
1031    )
1032}
1033
1034/// List the user rules the upgrade held back.
1035///
1036/// # Errors
1037///
1038/// The user-rule file cannot be read.
1039pub fn review_list() -> Result<String> {
1040    note_cutoff();
1041    let rules = load()?;
1042    let held: Vec<&UserRule> = rules.iter().filter(|rule| is_held(rule)).collect();
1043    if held.is_empty() {
1044        return Ok("no older user rules await review\n".to_string());
1045    }
1046    let mut out = format!("{}\n", review_sentence(held.len()));
1047    for rule in held {
1048        out.push_str(&format!(
1049            "{}  {}  {}\n  ljos prefer --user --review --keep {}\n",
1050            rule.id,
1051            rule.written_at,
1052            one_line(&rule.text),
1053            rule.id
1054        ));
1055    }
1056    Ok(out)
1057}
1058
1059/// Keep one held user rule, from any caller. The keep's basis is
1060/// [`super::Basis::of_rule`]: the person's at a terminal of their own, else
1061/// the agent's, which `ljos rules` lists as unverified. The person can
1062/// still keep a rule an agent kept. `updated_at` moves, so a sync keeps
1063/// this copy.
1064///
1065/// # Errors
1066///
1067/// The id is not a held user rule, or the file cannot be written.
1068pub fn keep_held(id: &str) -> Result<String> {
1069    note_cutoff();
1070    let basis = super::Basis::of_rule();
1071    let open = |rule: &UserRule| {
1072        is_held(rule)
1073            || (basis == super::Basis::Person
1074                && rule.basis.as_deref() == Some(super::Basis::Agent.as_str())
1075                && from_before_cutoff(rule))
1076    };
1077    let mut rules = read_all()?;
1078    let ids: Vec<String> = rules
1079        .iter()
1080        .filter(|rule| open(rule))
1081        .map(|rule| rule.id.clone())
1082        .collect();
1083    let id = super::resolve_short_id("prefer", id.trim(), &ids)?;
1084    let Some(rule) = rules.iter_mut().find(|rule| rule.id == id && open(rule)) else {
1085        bail!("prefer: {id} is not an older user rule awaiting review");
1086    };
1087    rule.basis = Some(basis.as_str().to_string());
1088    rule.updated_at = super::now_utc();
1089    let shown = match basis {
1090        super::Basis::Person => format!("user rule {}\tkept\t{}\n", rule.id, rule.text),
1091        super::Basis::Agent => format!(
1092            "user rule {}\tkept as the agent's review; unverified until the person keeps it from a terminal of their own\t{}\n",
1093            rule.id, rule.text
1094        ),
1095    };
1096    store(&rules)?;
1097    Ok(shown)
1098}
1099
1100fn render_preface(standing: &[UserRule], held: &[UserRule], budget: usize) -> String {
1101    if budget == 0 || (standing.is_empty() && held.is_empty()) {
1102        return String::new();
1103    }
1104    let mut out = if standing.is_empty() {
1105        String::new()
1106    } else {
1107        render(standing, budget)
1108    };
1109    if held.is_empty() {
1110        return out;
1111    }
1112    let gap = usize::from(!out.is_empty());
1113    let room = budget.saturating_sub(out.chars().count() + gap);
1114    let rest = render_headed(
1115        held,
1116        "Older user rules, not confirmed (`ljos prefer --user --review`):\n",
1117        room,
1118    );
1119    if rest.is_empty() {
1120        return out;
1121    }
1122    if !out.is_empty() {
1123        out.push('\n');
1124    }
1125    out.push_str(&rest);
1126    if out.chars().count() > budget {
1127        out = out.chars().take(budget).collect();
1128    }
1129    out.trim_end().to_string()
1130}
1131
1132fn render(rules: &[UserRule], budget: usize) -> String {
1133    render_headed(
1134        rules,
1135        "User rules (standing, shared by every seat on this machine):\n",
1136        budget,
1137    )
1138}
1139
1140fn render_headed(rules: &[UserRule], header: &str, budget: usize) -> String {
1141    if rules.is_empty() || budget == 0 {
1142        return String::new();
1143    }
1144    let mut body = String::new();
1145    let mut shown = 0usize;
1146    for rule in rules {
1147        let line = format!("- {}\n", one_line(&rule.text));
1148        let note = omission(rules.len() - (shown + 1));
1149        let used = header.chars().count()
1150            + body.chars().count()
1151            + line.chars().count()
1152            + note.chars().count();
1153        if used <= budget {
1154            body.push_str(&line);
1155            shown += 1;
1156            continue;
1157        }
1158        if shown == 0 {
1159            let note = omission(rules.len().saturating_sub(1));
1160            let room = budget.saturating_sub(header.chars().count() + note.chars().count());
1161            let take = room.saturating_sub(6);
1162            if take > 0 {
1163                let cut: String = one_line(&rule.text).chars().take(take).collect();
1164                body.push_str(&format!("- {cut}...\n"));
1165                shown = 1;
1166            }
1167        }
1168        break;
1169    }
1170    if shown == 0 {
1171        return String::new();
1172    }
1173    let mut out = format!("{header}{body}");
1174    let left = rules.len() - shown;
1175    if left > 0 {
1176        out.push_str(&omission(left));
1177    }
1178    if out.chars().count() > budget {
1179        out = out.chars().take(budget).collect();
1180    }
1181    out.trim_end().to_string()
1182}
1183
1184/// `body`, with the user-rule preface in front. An empty preface leaves
1185/// `body` as it is.
1186#[must_use]
1187pub fn prepend(body: String) -> String {
1188    let head = preface();
1189    if head.is_empty() {
1190        body
1191    } else if body.is_empty() {
1192        head
1193    } else {
1194        format!("{head}\n{body}")
1195    }
1196}
1197
1198/// `text` with one leading preface removed, so a session line and a prompt
1199/// note that both carry it are not pasted twice.
1200#[must_use]
1201pub fn without_preface(text: &str) -> String {
1202    let head = preface();
1203    if head.is_empty() {
1204        return text.to_string();
1205    }
1206    text.trim_start()
1207        .strip_prefix(&head)
1208        .map(|rest| rest.trim_start().to_string())
1209        .unwrap_or_else(|| text.to_string())
1210}
1211
1212fn as_argv(rule: &UserRule) -> Option<Rule> {
1213    Some(Rule {
1214        pattern: rule.pattern.clone()?,
1215        // A row with no verdict still matches. The report below is a
1216        // warning, so a missing verdict does not become an ask.
1217        verdict: rule.verdict.clone().unwrap_or_else(|| "warn".to_string()),
1218        reason: rule.reason.clone(),
1219    })
1220}
1221
1222fn fired(rule: &UserRule) -> Rule {
1223    let why = rule.reason.trim();
1224    let text = rule.text.trim();
1225    let reason = if text.is_empty() || text == why {
1226        why.to_string()
1227    } else {
1228        format!("{text} ({why})")
1229    };
1230    reported(
1231        rule,
1232        Rule {
1233            pattern: format!("user-rule:{}", rule.id),
1234            verdict: "deny".into(),
1235            reason,
1236        },
1237    )
1238}
1239
1240/// `law`, fired by `rule`, as a guard reports it. The call runs.
1241/// An unconfirmed rule is the warning labelled `unverified-rule`.
1242/// A confirmed deny, ask or warn is the warning labelled `user-rule`.
1243/// The rule's own reason is kept, and the pattern it matched is named.
1244fn reported(rule: &UserRule, law: Rule) -> Rule {
1245    let pattern = law.pattern;
1246    let reason = law.reason;
1247    if let Some(why) = unconfirmed(rule) {
1248        return Rule {
1249            pattern: "unverified-rule".into(),
1250            verdict: "warn".into(),
1251            reason: format!(
1252                "unverified, not confirmed as the person's rule ({why}): {reason} (rule {pattern})"
1253            ),
1254        };
1255    }
1256    if matches!(law.verdict.as_str(), "deny" | "ask" | "block" | "warn") {
1257        return Rule {
1258            pattern: "user-rule".into(),
1259            verdict: "warn".into(),
1260            reason: format!("{reason} (rule {pattern})"),
1261        };
1262    }
1263    Rule {
1264        pattern,
1265        verdict: law.verdict,
1266        reason,
1267    }
1268}
1269
1270struct Post {
1271    bodies: Vec<String>,
1272    reply_values: Vec<String>,
1273}
1274
1275/// Say a failed user-rules read once per process. Each hook is its own
1276/// process, so a burst of calls does not repeat the line.
1277fn note_rules_read_error(err: &anyhow::Error) {
1278    use std::sync::atomic::{AtomicBool, Ordering};
1279    static NOTED: AtomicBool = AtomicBool::new(false);
1280    if NOTED.swap(true, Ordering::Relaxed) {
1281        return;
1282    }
1283    eprintln!("ljos: {err:#}");
1284}
1285
1286/// A GitHub write the rules cover, warned on because the file could not
1287/// be used. The reason names the file.
1288fn github_write_unchecked(err: &anyhow::Error) -> Rule {
1289    note_rules_read_error(err);
1290    Rule {
1291        pattern: "user-rule".into(),
1292        verdict: "warn".into(),
1293        reason: format!(
1294            "user rules could not be read ({err:#}), so this GitHub write was not checked against them (rule user-rule:file)"
1295        ),
1296    }
1297}
1298
1299/// The person's allow that covers `line`, if any: a user rule with the
1300/// verdict `allow` and the person's basis whose pattern matches a command
1301/// of the line. It clears the seat's own warnings for that line.
1302/// The law in policyd is not read here and still judges the line.
1303#[must_use]
1304pub fn allows(line: &str) -> Option<UserRule> {
1305    if line.trim().is_empty() {
1306        return None;
1307    }
1308    let rules = load().ok()?;
1309    let cues = super::LineCues::of(line);
1310    rules.into_iter().find(|rule| {
1311        !rule.deleted
1312            && rule.verdict.as_deref() == Some("allow")
1313            && rule.basis.as_deref() == Some("person")
1314            && rule
1315                .pattern
1316                .as_deref()
1317                .is_some_and(|pattern| cues.fires(pattern))
1318    })
1319}
1320
1321/// The user-rule verdict for a shell line, if one fires.
1322#[must_use]
1323pub fn guard_line(line: &str) -> Option<Rule> {
1324    if line.trim().is_empty() {
1325        return None;
1326    }
1327    let rules = match load() {
1328        Ok(rules) => rules,
1329        Err(err) => {
1330            if posts_of(line).is_empty() {
1331                return None;
1332            }
1333            return Some(github_write_unchecked(&err));
1334        }
1335    };
1336    if rules.is_empty() {
1337        return None;
1338    }
1339    let (rows, argv): (Vec<&UserRule>, Vec<Rule>) = rules
1340        .iter()
1341        .filter_map(|rule| Some((rule, as_argv(rule)?)))
1342        .unzip();
1343    // The rule verdict_for gives a line nested too deep is no row.
1344    let report = |law: &Rule| match argv.iter().position(|row| std::ptr::eq(row, law)) {
1345        Some(at) => reported(rows[at], law.clone()),
1346        None => law.clone(),
1347    };
1348    if let Some(hit) = super::verdict_for(&argv, line).filter(|rule| rule.verdict == "deny") {
1349        return Some(report(hit));
1350    }
1351    if let Some(hit) = outbound(line, &rules) {
1352        return Some(hit);
1353    }
1354    super::verdict_for(&argv, line).map(report)
1355}
1356
1357/// The user-rule verdict for a hook payload that names a tool, if the tool
1358/// is a GitHub comment, review, issue or pull-request write.
1359#[must_use]
1360pub fn guard_payload(raw: &str) -> Option<Rule> {
1361    let value: Value = serde_json::from_str(raw.trim()).ok()?;
1362    let tool = value
1363        .get("tool_name")
1364        .or_else(|| value.get("toolName"))
1365        .and_then(Value::as_str)
1366        .or_else(|| value.pointer("/toolCall/name").and_then(Value::as_str))?;
1367    let server = ["mcp_server_name", "mcpServerName", "server_name", "server"]
1368        .iter()
1369        .find_map(|key| value.get(*key).and_then(Value::as_str));
1370    // The MCP server binary. A `command` inside tool_input is an argument
1371    // of the tool, not this field.
1372    let command = value.get("command").and_then(Value::as_str);
1373    let input = value
1374        .get("tool_input")
1375        .or_else(|| value.get("toolInput"))
1376        .or_else(|| value.pointer("/toolCall/args"));
1377    let parsed = match input {
1378        Some(Value::String(text)) => {
1379            serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone()))
1380        }
1381        Some(other) => other.clone(),
1382        None => Value::Null,
1383    };
1384    guard_mcp(tool, server, command, &parsed)
1385}
1386
1387fn guard_mcp(
1388    tool: &str,
1389    server: Option<&str>,
1390    command: Option<&str>,
1391    input: &Value,
1392) -> Option<Rule> {
1393    if !github_write(tool, server, command) && !cursor_http_mcp_write(tool, input) {
1394        return None;
1395    }
1396    let rules = match load() {
1397        Ok(rules) => rules,
1398        Err(err) => return Some(github_write_unchecked(&err)),
1399    };
1400    let post = mcp_fields(input, tool);
1401    rules.iter().find(|rule| post_hits(rule, &post)).map(fired)
1402}
1403
1404fn github_server(name: &str) -> bool {
1405    name.to_ascii_lowercase().contains("github")
1406}
1407
1408/// the editor harness names an HTTP MCP tool `MCP:<tool>` and sends no server field.
1409/// A write verb with `owner` and `repo` is the same GitHub write as
1410/// `mcp__github__<tool>`.
1411fn cursor_http_mcp_write(tool: &str, input: &Value) -> bool {
1412    let Some(short) = tool.strip_prefix("MCP:") else {
1413        return false;
1414    };
1415    if short.is_empty() {
1416        return false;
1417    }
1418    let short = short.to_ascii_lowercase();
1419    if short.starts_with("get_")
1420        || short.starts_with("list_")
1421        || short.starts_with("search_")
1422        || short.starts_with("read_")
1423        || short.contains("_read")
1424    {
1425        return false;
1426    }
1427    if !["comment", "review", "issue", "pull"]
1428        .iter()
1429        .any(|kind| short.contains(kind))
1430    {
1431        return false;
1432    }
1433    let Some(obj) = input.as_object() else {
1434        return false;
1435    };
1436    obj.contains_key("owner") && obj.contains_key("repo")
1437}
1438
1439fn github_write(tool: &str, server: Option<&str>, command: Option<&str>) -> bool {
1440    // The seat's own tool on its configured server is not a GitHub write,
1441    // even when that server key contains "github". A payload command that
1442    // is not the seat server is a foreign tool.
1443    if crate::subagent::is_confirmed_seat_call(tool, server, command) {
1444        return false;
1445    }
1446    let tool_l = tool.to_ascii_lowercase();
1447    let embedded = crate::subagent::mcp_prefixed_tool(&tool_l).map(|(name, _)| name.to_string());
1448    let short = tool_l.rsplit("__").next().unwrap_or(&tool_l);
1449    let short = short.rsplit('/').next().unwrap_or(short);
1450    let server_github =
1451        server.is_some_and(github_server) || embedded.as_deref().is_some_and(github_server);
1452    let githubish = server_github || tool_l.contains("github");
1453    if !githubish {
1454        return false;
1455    }
1456    if short.starts_with("get_")
1457        || short.starts_with("list_")
1458        || short.starts_with("search_")
1459        || short.starts_with("read_")
1460        || short.contains("_read")
1461    {
1462        return false;
1463    }
1464    // mcp__github__ljos_NAME is a GitHub server wearing the seat's prefix.
1465    if server_github && short.starts_with("ljos_") {
1466        return true;
1467    }
1468    ["comment", "review", "issue", "pull"]
1469        .iter()
1470        .any(|kind| short.contains(kind))
1471}
1472
1473fn outbound(line: &str, rules: &[UserRule]) -> Option<Rule> {
1474    let posts = posts_of(line);
1475    if posts.is_empty() {
1476        return None;
1477    }
1478    rules.iter().find_map(|rule| {
1479        posts
1480            .iter()
1481            .any(|post| post_hits(rule, post))
1482            .then(|| fired(rule))
1483    })
1484}
1485
1486fn post_hits(rule: &UserRule, post: &Post) -> bool {
1487    let mentioned = post
1488        .bodies
1489        .iter()
1490        .any(|body| mentions(&rule.deny_mention, body));
1491    let replies = post
1492        .reply_values
1493        .iter()
1494        .any(|value| identifies(&rule.deny_reply_to, value));
1495    mentioned || replies
1496}
1497
1498fn posts_of(line: &str) -> Vec<Post> {
1499    let mut out = Vec::new();
1500    for sub in super::nested_lines(line, 0) {
1501        for raw in super::raw_segments(&sub) {
1502            let words = super::shell_words(&raw);
1503            if words.is_empty() {
1504                continue;
1505            }
1506            let (at, _) = super::prefix_end(&words);
1507            if at >= words.len() {
1508                continue;
1509            }
1510            if let Some(post) = gh_post(&words[at..]) {
1511                out.push(post);
1512            }
1513        }
1514    }
1515    out
1516}
1517
1518fn gh_post(words: &[String]) -> Option<Post> {
1519    let bin = words.first()?.rsplit(['/', '\\']).next()?;
1520    if bin != "gh" {
1521        return None;
1522    }
1523    let args = skip_global(&words[1..]);
1524    let group = args.first()?.as_str();
1525    let verb = args.get(1).map(String::as_str);
1526    let write = match (group, verb) {
1527        ("pr" | "issue", Some("comment" | "review" | "create" | "edit")) => true,
1528        ("api", _) => api_write(&args[1..]),
1529        _ => false,
1530    };
1531    if !write {
1532        return None;
1533    }
1534    Some(Post {
1535        bodies: bodies_of(&args),
1536        reply_values: reply_values_of(&args),
1537    })
1538}
1539
1540fn skip_global(words: &[String]) -> Vec<String> {
1541    let mut i = 0;
1542    while i < words.len() {
1543        let word = words[i].as_str();
1544        if word == "--" {
1545            return words[i + 1..].to_vec();
1546        }
1547        if !word.starts_with('-') {
1548            break;
1549        }
1550        let takes_value = matches!(word, "-R" | "--repo" | "--hostname");
1551        i += if word.contains('=') || !takes_value {
1552            1
1553        } else {
1554            2
1555        };
1556    }
1557    words[i..].to_vec()
1558}
1559
1560fn api_write(args: &[String]) -> bool {
1561    matches!(method_of(args).as_deref(), Some("POST" | "PATCH" | "PUT"))
1562}
1563
1564fn method_of(args: &[String]) -> Option<String> {
1565    let mut i = 0;
1566    while i < args.len() {
1567        let word = args[i].as_str();
1568        if let Some(value) = word
1569            .strip_prefix("--method=")
1570            .or_else(|| word.strip_prefix("-X="))
1571        {
1572            return Some(value.to_ascii_uppercase());
1573        }
1574        if word == "--method" || word == "-X" {
1575            return args.get(i + 1).map(|value| value.to_ascii_uppercase());
1576        }
1577        if let Some(value) = word.strip_prefix("-X") {
1578            if !value.is_empty() && !value.starts_with('-') {
1579                return Some(value.to_ascii_uppercase());
1580            }
1581        }
1582        i += 1;
1583    }
1584    None
1585}
1586
1587const BODY_FLAGS: &[&str] = &["--body", "-b", "--title", "-t", "--subject"];
1588const FIELD_FLAGS: &[&str] = &["-f", "-F", "--field", "--raw-field"];
1589const BODY_KEYS: &[&str] = &["body", "title", "comment", "review"];
1590const REPLY_KEYS: &[&str] = &["in_reply_to", "in_reply_to_user", "reply_to", "reply-to"];
1591
1592fn bodies_of(args: &[String]) -> Vec<String> {
1593    let mut out = flag_values(args, BODY_FLAGS);
1594    for (key, value) in assignments(args) {
1595        if BODY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1596            out.push(value);
1597        }
1598    }
1599    out
1600}
1601
1602fn reply_values_of(args: &[String]) -> Vec<String> {
1603    let mut out = flag_values(args, &["--reply-to", "--reply-to-user"]);
1604    for (key, value) in assignments(args) {
1605        if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1606            out.push(value);
1607        }
1608    }
1609    for arg in args {
1610        if let Some((_, query)) = arg.split_once('?') {
1611            for pair in query.split('&') {
1612                if let Some((key, value)) = pair.split_once('=') {
1613                    if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1614                        out.push(value.to_string());
1615                    }
1616                }
1617            }
1618        }
1619    }
1620    out
1621}
1622
1623fn assignments(args: &[String]) -> Vec<(String, String)> {
1624    let mut out = Vec::new();
1625    let mut i = 0;
1626    while i < args.len() {
1627        let word = &args[i];
1628        if FIELD_FLAGS.contains(&word.as_str()) {
1629            if let Some(next) = args.get(i + 1) {
1630                if let Some((key, value)) = next.split_once('=') {
1631                    out.push((key.to_string(), value.to_string()));
1632                }
1633            }
1634            i += 2;
1635            continue;
1636        }
1637        if let Some(rest) = word
1638            .strip_prefix("--raw-field=")
1639            .or_else(|| word.strip_prefix("--field="))
1640        {
1641            if let Some((key, value)) = rest.split_once('=') {
1642                out.push((key.to_string(), value.to_string()));
1643            }
1644        }
1645        i += 1;
1646    }
1647    out
1648}
1649
1650fn flag_values(args: &[String], names: &[&str]) -> Vec<String> {
1651    let mut out = Vec::new();
1652    let mut i = 0;
1653    while i < args.len() {
1654        let word = &args[i];
1655        if let Some((key, value)) = word.split_once('=') {
1656            if names.contains(&key) && !value.is_empty() {
1657                out.push(value.to_string());
1658            }
1659        }
1660        if names.contains(&word.as_str()) {
1661            if let Some(next) = args.get(i + 1) {
1662                if !next.starts_with('-') {
1663                    out.push(next.clone());
1664                    i += 2;
1665                    continue;
1666                }
1667            }
1668        }
1669        i += 1;
1670    }
1671    out
1672}
1673
1674fn mcp_fields(input: &Value, tool: &str) -> Post {
1675    let mut bodies = Vec::new();
1676    let mut reply_values = Vec::new();
1677    collect_keyed(
1678        input,
1679        &["body", "comment", "title", "review", "message", "content"],
1680        &mut bodies,
1681    );
1682    collect_keyed(
1683        input,
1684        &[
1685            "in_reply_to",
1686            "in_reply_to_user",
1687            "inReplyTo",
1688            "reply_to",
1689            "replyTo",
1690            "user",
1691            "author",
1692            "reviewer",
1693        ],
1694        &mut reply_values,
1695    );
1696    if tool.to_ascii_lowercase().contains("reply") {
1697        collect_strings(input, &mut reply_values);
1698    }
1699    Post {
1700        bodies,
1701        reply_values,
1702    }
1703}
1704
1705fn collect_keyed(value: &Value, keys: &[&str], out: &mut Vec<String>) {
1706    match value {
1707        Value::Object(map) => {
1708            for (key, child) in map {
1709                if keys.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1710                    collect_strings(child, out);
1711                } else {
1712                    collect_keyed(child, keys, out);
1713                }
1714            }
1715        }
1716        Value::Array(items) => {
1717            for child in items {
1718                collect_keyed(child, keys, out);
1719            }
1720        }
1721        _ => {}
1722    }
1723}
1724
1725fn collect_strings(value: &Value, out: &mut Vec<String>) {
1726    match value {
1727        Value::String(text) => out.push(text.clone()),
1728        Value::Array(items) => {
1729            for child in items {
1730                collect_strings(child, out);
1731            }
1732        }
1733        Value::Object(map) => {
1734            for child in map.values() {
1735                collect_strings(child, out);
1736            }
1737        }
1738        _ => {}
1739    }
1740}
1741
1742fn mentions(people: &People, text: &str) -> bool {
1743    people
1744        .handles
1745        .iter()
1746        .any(|handle| mentions_handle(text, handle))
1747        || people.names.iter().any(|name| mentions_name(text, name))
1748}
1749
1750fn identifies(people: &People, value: &str) -> bool {
1751    let value = value.trim();
1752    if value.is_empty() {
1753        return false;
1754    }
1755    let bare = value.trim_start_matches('@').to_ascii_lowercase();
1756    people.handles.iter().any(|handle| {
1757        let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
1758        !handle.is_empty() && (bare == handle || mentions_handle(value, &handle))
1759    }) || people.names.iter().any(|name| mentions_name(value, name))
1760}
1761
1762fn mentions_handle(text: &str, handle: &str) -> bool {
1763    let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
1764    if handle.is_empty() {
1765        return false;
1766    }
1767    let lower = text.to_ascii_lowercase();
1768    let needle = format!("@{handle}");
1769    let bytes = lower.as_bytes();
1770    let mark = needle.as_bytes();
1771    let mut i = 0;
1772    while i + mark.len() <= bytes.len() {
1773        if &bytes[i..i + mark.len()] == mark {
1774            let after = bytes.get(i + mark.len()).copied();
1775            if after.is_none_or(|c| !is_handle_char(c as char)) {
1776                return true;
1777            }
1778        }
1779        i += 1;
1780    }
1781    false
1782}
1783
1784fn is_handle_char(c: char) -> bool {
1785    c.is_ascii_alphanumeric() || c == '-'
1786}
1787
1788fn mentions_name(text: &str, name: &str) -> bool {
1789    let name = name.trim();
1790    if name.is_empty() {
1791        return false;
1792    }
1793    let text: Vec<char> = text.to_lowercase().chars().collect();
1794    let name: Vec<char> = name.to_lowercase().chars().collect();
1795    let mut i = 0;
1796    while i + name.len() <= text.len() {
1797        if text[i..i + name.len()] == name[..] {
1798            let before = i == 0 || !text[i - 1].is_alphanumeric();
1799            let after = i + name.len();
1800            let after_ok = after == text.len() || !text[after].is_alphanumeric();
1801            if before && after_ok {
1802                return true;
1803            }
1804        }
1805        i += 1;
1806    }
1807    false
1808}
1809
1810#[cfg(test)]
1811mod tests {
1812    use super::*;
1813
1814    fn isolated() -> (super::super::EnvLock, tempfile::TempDir) {
1815        let lock = super::super::test_env_lock();
1816        let dir = tempfile::TempDir::new().unwrap();
1817        unsafe {
1818            std::env::set_var("XDG_CONFIG_HOME", dir.path().join("config"));
1819            std::env::set_var("HOME", dir.path());
1820            std::env::set_var("LJOS_GUARD", "on");
1821            std::env::set_var("LJOS_HOLD", "rohit");
1822            std::env::set_var("LJOS_SEAT", "claude");
1823            std::env::set_var("PACKSET_URL", "off");
1824            std::env::set_var("PACKSET_WORKSPACE", "seat-a");
1825            std::env::set_var("LJOS_USER_RULES_SYNC", "off");
1826        }
1827        super::super::PERSON_AT_TERMINAL.with(|p| p.set(true));
1828        (lock, dir)
1829    }
1830
1831    fn alan() -> UserRule {
1832        write_preference(
1833            "never post or reply to Alan (ocaisa)",
1834            "he asked not to be pinged",
1835            &People {
1836                handles: vec!["ocaisa".into()],
1837                names: vec!["Alan".into()],
1838            },
1839            &People {
1840                handles: vec!["@ocaisa".into()],
1841                names: vec!["Alan".into()],
1842            },
1843        )
1844        .unwrap()
1845    }
1846
1847    /// These tests write a user rule as the person at a terminal. A
1848    /// runner's variables on the shell that runs `cargo test` must not make
1849    /// them an agent's, so each is run again in a child that carries them.
1850    #[test]
1851    fn the_user_rule_tests_pass_under_a_runner_environment() {
1852        let runner: &[(&str, &str)] = &[
1853            ("CURSOR_CONVERSATION_ID", "0199a1b2-c3d4-e5f6-runner"),
1854            ("CURSOR_AGENT", "1"),
1855            ("CLAUDECODE", "1"),
1856            ("CLAUDE_CODE_SESSION_ID", "0199a1b2-claude-session"),
1857            ("CODEX_THREAD_ID", "0199a1b2-codex-thread"),
1858        ];
1859        for test in [
1860            "a_user_rule_is_surfaced_in_a_second_seat",
1861            "the_guard_warns_on_a_mention_and_a_reply_and_allows_another_comment",
1862            "two_homes_with_different_rules_converge",
1863        ] {
1864            let test = format!("user_rules::tests::{test}");
1865            for env in [runner, &[("CURSOR_CONVERSATION_ID", "x")]] {
1866                let mut cmd = std::process::Command::new(std::env::current_exe().unwrap());
1867                cmd.args(["--exact", &test, "--test-threads=1"]);
1868                for (key, value) in env {
1869                    cmd.env(key, value);
1870                }
1871                let out = cmd.output().unwrap();
1872                let said = String::from_utf8_lossy(&out.stdout).into_owned()
1873                    + &String::from_utf8_lossy(&out.stderr);
1874                assert!(out.status.success(), "{test} under {env:?}:\n{said}");
1875                assert!(said.contains("1 passed"), "{test} under {env:?}:\n{said}");
1876            }
1877        }
1878    }
1879
1880    #[test]
1881    fn a_user_rule_is_surfaced_in_a_second_seat() {
1882        let (_lock, dir) = isolated();
1883        let written = alan();
1884        assert_eq!(written.author, "rohit");
1885        assert!(written.due_at.is_none());
1886        unsafe {
1887            std::env::set_var("LJOS_SEAT", "codex");
1888            std::env::set_var("PACKSET_WORKSPACE", "seat-b");
1889        }
1890        let path = rules_path().unwrap();
1891        assert!(path.starts_with(dir.path().join("config")));
1892        assert!(!path.to_string_lossy().contains("codex"));
1893        assert!(!path.to_string_lossy().contains("claude"));
1894        let head = preface();
1895        assert!(head.starts_with("User rules"), "{head}");
1896        assert!(head.contains("never post or reply to Alan"), "{head}");
1897        let start = super::super::session_start_for(Some("seat-two"));
1898        assert!(start.starts_with("User rules"), "{start}");
1899        let at = start.find("never post or reply to Alan").unwrap();
1900        let on = start.find("ljos is on").unwrap();
1901        assert!(at < on, "{start}");
1902        let call = super::super::HookCall {
1903            event: "UserPromptSubmit".into(),
1904            cue: "open an easybuild pull request".into(),
1905            session: Some("seat-two".into()),
1906            shape: super::super::HookShape::Asks,
1907        };
1908        let (note, ids) = super::super::hook_note(&call, 5);
1909        assert!(note.starts_with("User rules"), "{note}");
1910        assert!(note.contains("never post or reply to Alan"), "{note}");
1911        assert!(ids.iter().all(|id| !id.starts_with("ur-")), "{ids:?}");
1912        let claude = super::super::hook_call(
1913            r#"{"hook_event_name":"UserPromptSubmit","session_id":"claude-1","prompt":"open an easybuild pull request"}"#,
1914        );
1915        let (claude_note, _) = super::super::hook_note(&claude, 5);
1916        let claude_out = super::super::hook_output(&claude, &claude_note);
1917        assert!(claude_out.contains("additionalContext"), "{claude_out}");
1918        assert!(
1919            claude_out.contains("never post or reply to Alan"),
1920            "{claude_out}"
1921        );
1922        let agy = super::super::hook_call_as(
1923            r#"{"conversationId":"agy-1","invocationNum":1,"prompt":"ship the easybuild change"}"#,
1924            Some("PreInvocation"),
1925        );
1926        assert_eq!(agy.event, "UserPromptSubmit");
1927        assert_eq!(agy.shape, super::super::HookShape::Steps);
1928        let (agy_note, _) = super::super::hook_note(&agy, 5);
1929        let agy_out = super::super::hook_output(&agy, &agy_note);
1930        assert!(agy_out.contains("injectSteps"), "{agy_out}");
1931        assert!(agy_out.contains("never post or reply to Alan"), "{agy_out}");
1932        unsafe {
1933            std::env::set_var("XDG_CONFIG_HOME", dir.path().join("other"));
1934        }
1935        assert!(preface().is_empty(), "a second config home has no rules");
1936    }
1937
1938    #[test]
1939    fn a_user_rule_does_not_decay_after_simulated_time() {
1940        let (_lock, _dir) = isolated();
1941        let written = alan();
1942        let later = "2099-01-01T00:00:00Z";
1943        assert_eq!(sweep(later).unwrap(), 0);
1944        assert_eq!(review_due(later), 0);
1945        assert!(!is_due(&written, later));
1946        let kept = load().unwrap();
1947        assert_eq!(kept.len(), 1);
1948        assert_eq!(kept[0].text, written.text);
1949        assert!(kept[0].due_at.is_none());
1950        let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
1951        assert!(!raw.contains("\"due_at\""), "{raw}");
1952        let mut file: File = serde_json::from_str(&raw).unwrap();
1953        file.rules.push(UserRule {
1954            id: "ur-expired".into(),
1955            text: "a row that was given a date".into(),
1956            author: "rohit".into(),
1957            reason: "fixture".into(),
1958            pattern: None,
1959            verdict: None,
1960            deny_mention: People::default(),
1961            deny_reply_to: People::default(),
1962            written_at: "2020-01-01T00:00:00Z".into(),
1963            updated_at: "2020-01-01T00:00:00Z".into(),
1964            deleted: false,
1965            from: None,
1966            basis: None,
1967            lesson_hash: None,
1968            due_at: Some("2020-01-02T00:00:00Z".into()),
1969        });
1970        std::fs::write(
1971            rules_path().unwrap(),
1972            serde_json::to_string_pretty(&file).unwrap(),
1973        )
1974        .unwrap();
1975        assert_eq!(sweep(later).unwrap(), 1);
1976        let kept = load().unwrap();
1977        assert_eq!(kept.len(), 1);
1978        assert_eq!(kept[0].id, written.id);
1979        assert_eq!(review_due(later), 0);
1980    }
1981
1982    #[test]
1983    fn the_preface_stays_inside_its_budget_and_keeps_the_first_rule() {
1984        let (_lock, _dir) = isolated();
1985        let first = "a".repeat(80);
1986        write_preference(&first, "first", &People::default(), &People::default()).unwrap();
1987        for n in 0..30 {
1988            let text = format!("rule {n} {}", "b".repeat(200));
1989            write_preference(&text, "later", &People::default(), &People::default()).unwrap();
1990        }
1991        let head = preface();
1992        assert!(
1993            head.chars().count() <= PREFACE_BUDGET,
1994            "{}",
1995            head.chars().count()
1996        );
1997        assert!(head.contains(&first[..40]), "{head}");
1998        assert!(head.contains("omitted"), "{head}");
1999    }
2000
2001    #[test]
2002    fn the_guard_warns_on_a_mention_and_a_reply_and_allows_another_comment() {
2003        let (_lock, _dir) = isolated();
2004        write_preference(
2005            "never post or reply to Alan (ocaisa)",
2006            "he asked not to be pinged",
2007            &People::default(),
2008            &People::default(),
2009        )
2010        .unwrap();
2011        let mention = "gh pr comment 12 --body 'please ask @ocaisa'";
2012        assert!(
2013            super::super::static_guard(mention).is_none(),
2014            "prose alone is not a deny"
2015        );
2016        alan();
2017        let denied = super::super::static_guard(mention).expect("mention");
2018        assert_eq!(denied.verdict, "warn");
2019        assert_eq!(denied.pattern, "user-rule");
2020        assert!(denied.reason.contains("(rule user-rule:"), "{denied:?}");
2021        assert!(
2022            denied.reason.contains("ocaisa") || denied.reason.contains("Alan"),
2023            "{denied:?}"
2024        );
2025        let reply = "gh api --method POST repos/org/repo/issues/comments/7/replies -f body=seen -F in_reply_to_user=ocaisa";
2026        let denied = super::super::static_guard(reply).expect("reply");
2027        assert_eq!(denied.verdict, "warn");
2028        assert_eq!(denied.pattern, "user-rule");
2029        let allowed = "gh pr comment 12 --body 'tests passed'";
2030        assert!(
2031            super::super::static_guard(allowed).is_none(),
2032            "unrelated comment"
2033        );
2034        assert!(super::super::static_guard("gh pr comment 12 --body 'see ocaisa'").is_none());
2035        assert!(
2036            super::super::static_guard("gh pr comment 12 --body 'please ask @ocaisa2'").is_none()
2037        );
2038        assert!(
2039            super::super::static_guard("gh pr comment 12 --body 'alanine is a molecule'").is_none()
2040        );
2041        let named =
2042            super::super::static_guard("gh pr comment 12 --body 'ask Alan to look'").expect("name");
2043        assert_eq!(named.verdict, "warn");
2044        assert_eq!(named.pattern, "user-rule");
2045        assert!(super::super::static_guard("gh api user/ocaisa").is_none());
2046        assert!(super::super::static_guard("echo please ask @ocaisa").is_none());
2047        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"}}"#;
2048        assert!(guard_payload(mcp).is_some());
2049        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"}}"#;
2050        assert!(guard_payload(mcp_reply).is_some());
2051        let mcp_ok = r#"{"tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"tests passed"}}"#;
2052        assert!(guard_payload(mcp_ok).is_none());
2053        let mcp_read = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"body":"@ocaisa"}}"#;
2054        assert!(guard_payload(mcp_read).is_none());
2055        let cursor = super::super::hook_call(
2056            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"}}"#,
2057        );
2058        let verdict = guard_payload(
2059            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"}}"#,
2060        )
2061        .unwrap();
2062        let out = super::super::hook_output_ruled(&cursor, "", Some(&verdict));
2063        assert!(out.contains("\"permission\":\"allow\""), "{out}");
2064        assert!(out.contains(&verdict.warning()), "{out}");
2065        let payload = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa"}}"#;
2066        let (out, code) = super::super::cursor_side_answer(payload, None).unwrap();
2067        assert_eq!(code, 0, "{out}");
2068        assert!(out.contains("\"permission\":\"allow\""), "{out}");
2069        assert!(out.contains("ljos: user-rule:"), "{out}");
2070        assert!(out.contains("ocaisa") || out.contains("Alan"), "{out}");
2071    }
2072
2073    fn rules_file() -> std::path::PathBuf {
2074        let path = rules_path().unwrap();
2075        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2076        let _ = std::fs::remove_dir_all(&path);
2077        let _ = std::fs::remove_file(&path);
2078        path
2079    }
2080
2081    /// A missing file is an empty list. A FIFO, a directory, and a file
2082    /// over the cap fail at once, without reading the bytes.
2083    #[test]
2084    fn a_rules_file_must_be_a_small_regular_file() {
2085        let (_lock, _dir) = isolated();
2086        let path = rules_file();
2087        let _ = std::fs::remove_file(&path);
2088        assert!(load().unwrap().is_empty(), "a missing file is no rules");
2089
2090        std::fs::create_dir(&path).unwrap();
2091        let err = load().unwrap_err();
2092        assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
2093        let _ = std::fs::remove_dir(&path);
2094
2095        let file = std::fs::File::create(&path).unwrap();
2096        file.set_len(RULES_FILE_CAP + 1).unwrap();
2097        drop(file);
2098        let err = load().unwrap_err();
2099        assert!(format!("{err:#}").contains("byte cap"), "{err:#}");
2100        std::fs::remove_file(&path).unwrap();
2101
2102        let c_path = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
2103        // SAFETY: mkfifo creates a FIFO at this temp path with the given mode.
2104        assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0);
2105        let started = std::time::Instant::now();
2106        let (tx, rx) = std::sync::mpsc::channel();
2107        std::thread::spawn(move || {
2108            let _ = tx.send(load().map(|_| ()));
2109        });
2110        let err = rx
2111            .recv_timeout(std::time::Duration::from_secs(2))
2112            .expect("load blocked on a FIFO")
2113            .expect_err("a FIFO is not a rules file");
2114        assert!(
2115            started.elapsed() < std::time::Duration::from_millis(500),
2116            "load took {:?}",
2117            started.elapsed()
2118        );
2119        assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
2120    }
2121
2122    /// A zero-byte file, a blank file, and a dangling symlink are not an
2123    /// empty rule list. A covered GitHub write is warned on and the reason
2124    /// names the path. A missing file and a real empty list are not.
2125    #[test]
2126    fn an_empty_rules_file_and_a_dangling_link_warn_on_a_github_write() {
2127        let (_lock, _dir) = isolated();
2128        let benign = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"tests passed"}}"#;
2129        let read_tool = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"owner":"org"}}"#;
2130        let path = rules_file();
2131        std::fs::write(&path, "").unwrap();
2132        let hit = guard_payload(benign).expect("empty file allowed a write");
2133        assert_eq!(hit.verdict, "warn");
2134        assert_eq!(hit.pattern, "user-rule");
2135        assert!(
2136            hit.reason.contains(&path.display().to_string()),
2137            "{}",
2138            hit.reason
2139        );
2140        assert!(guard_payload(read_tool).is_none(), "a read was denied");
2141        let shell = guard_line("gh pr comment 12 --body 'tests passed'").unwrap();
2142        assert!(
2143            shell.reason.contains(&path.display().to_string()),
2144            "{}",
2145            shell.reason
2146        );
2147        assert!(guard_line("gh issue list").is_none(), "a read was denied");
2148        std::fs::write(&path, " \n").unwrap();
2149        let hit = guard_payload(benign).expect("blank file allowed a write");
2150        assert!(
2151            hit.reason.contains(&path.display().to_string()),
2152            "{}",
2153            hit.reason
2154        );
2155        std::fs::remove_file(&path).unwrap();
2156        std::os::unix::fs::symlink(path.with_file_name("no-such-rules.json"), &path).unwrap();
2157        let hit = guard_payload(benign).expect("dangling link allowed a write");
2158        assert_eq!(hit.verdict, "warn");
2159        assert_eq!(hit.pattern, "user-rule");
2160        assert!(
2161            hit.reason.contains(&path.display().to_string()),
2162            "{}",
2163            hit.reason
2164        );
2165        let err = load().unwrap_err();
2166        assert!(format!("{err:#}").contains("dangling"), "{err:#}");
2167        assert!(
2168            format!("{err:#}").contains(&path.display().to_string()),
2169            "{err:#}"
2170        );
2171        std::fs::remove_file(&path).unwrap();
2172        assert!(
2173            guard_payload(benign).is_none(),
2174            "a missing file denied a write"
2175        );
2176        std::fs::write(&path, "{\"rules\":[]}\n").unwrap();
2177        assert!(
2178            guard_payload(benign).is_none(),
2179            "an empty rule list denied a write"
2180        );
2181    }
2182
2183    /// A file that has parsed, then is emptied or cut off, is still the
2184    /// last whole copy. A benign GitHub write is not warned on.
2185    #[test]
2186    fn a_rules_file_caught_empty_keeps_the_last_good_copy() {
2187        let (_lock, _dir) = isolated();
2188        let path = rules_file();
2189        let benign = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"tests passed"}}"#;
2190        std::fs::write(&path, "{\"rules\":[]}\n").unwrap();
2191        assert!(load().unwrap().is_empty());
2192        std::fs::write(&path, "").unwrap();
2193        assert!(
2194            load().unwrap().is_empty(),
2195            "an emptied file dropped the last copy"
2196        );
2197        assert!(
2198            guard_payload(benign).is_none(),
2199            "an emptied file denied a write"
2200        );
2201        std::fs::write(&path, "{\"rules\":[").unwrap();
2202        assert!(
2203            load().unwrap().is_empty(),
2204            "a partial file dropped the last copy"
2205        );
2206        assert!(
2207            guard_payload(benign).is_none(),
2208            "a partial file denied a write"
2209        );
2210    }
2211
2212    /// A file that is present and cannot be used warns on the GitHub writes
2213    /// the rules cover, and leaves reads and other MCP calls alone.
2214    #[test]
2215    fn a_bad_rules_file_warns_on_github_writes_and_allows_other_calls() {
2216        let (_lock, _dir) = isolated();
2217        let benign = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"tests passed"}}"#;
2218        let note = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"ljos_note","mcp_server_name":"ljos","command":"ljos-mcp","tool_input":{"text":"x"}}"#;
2219        let read_tool = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"owner":"org"}}"#;
2220        let other = r#"{"tool_name":"add_issue_comment","mcp_server_name":"ljos","tool_input":{"body":"cc @ocaisa"}}"#;
2221        let read_event = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeReadFile","file_path":"/etc/hostname"}"#;
2222        let path = rules_file();
2223        let cases: &[(&str, &str)] = &[
2224            ("unreadable", "{\"rules\":[]}"),
2225            ("malformed", "{\"rules\":["),
2226            ("wrongtype", "{\"rules\":{\"id\":1}}"),
2227        ];
2228        for (name, body) in cases {
2229            std::fs::write(&path, body).unwrap();
2230            if *name == "unreadable" {
2231                use std::os::unix::fs::PermissionsExt;
2232                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
2233            }
2234            let hit = guard_payload(benign).unwrap_or_else(|| panic!("{name}: allowed"));
2235            assert_eq!(hit.verdict, "warn", "{name}");
2236            assert_eq!(hit.pattern, "user-rule", "{name}: {hit:?}");
2237            assert!(
2238                hit.reason.contains(&path.display().to_string()),
2239                "{name}: {}",
2240                hit.reason
2241            );
2242            assert!(guard_payload(note).is_none(), "{name}: note denied");
2243            assert!(guard_payload(read_tool).is_none(), "{name}: read denied");
2244            assert!(guard_payload(other).is_none(), "{name}: other MCP denied");
2245            let (out, code) = super::super::cursor_side_answer(benign, None).unwrap();
2246            assert_eq!(code, 0, "{name}: {out}");
2247            assert!(out.contains("\"permission\":\"allow\""), "{name}: {out}");
2248            assert!(out.contains("was not checked"), "{name}: {out}");
2249            let (out, code) = super::super::cursor_side_answer(note, None).unwrap();
2250            assert_eq!(code, 0, "{name}: {out}");
2251            assert_eq!(out, "{\"permission\":\"allow\"}\n", "{name}: {out}");
2252            let (out, code) = super::super::cursor_side_answer(read_event, None).unwrap();
2253            assert_eq!(code, 0, "{name}: {out}");
2254            assert_eq!(out, "{\"permission\":\"allow\"}\n", "{name}: {out}");
2255            let shell = guard_line("gh pr comment 12 --body 'tests passed'").unwrap();
2256            assert_eq!(shell.verdict, "warn", "{name}");
2257            assert_eq!(shell.pattern, "user-rule", "{name}");
2258            assert!(
2259                shell.reason.contains(&path.display().to_string()),
2260                "{name}: {}",
2261                shell.reason
2262            );
2263            assert!(guard_line("ls").is_none(), "{name}: ls denied");
2264            assert!(
2265                guard_line("gh issue list").is_none(),
2266                "{name}: a GitHub read denied"
2267            );
2268            if *name == "unreadable" {
2269                use std::os::unix::fs::PermissionsExt;
2270                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2271            }
2272        }
2273        std::fs::remove_file(&path).unwrap();
2274        std::fs::create_dir(&path).unwrap();
2275        let hit = guard_payload(benign).expect("a directory denies");
2276        assert!(hit.reason.contains("not a regular file"), "{}", hit.reason);
2277        assert!(guard_payload(note).is_none());
2278        let _ = std::fs::remove_dir(&path);
2279
2280        assert!(
2281            guard_payload(benign).is_none(),
2282            "a missing file allows a GitHub write"
2283        );
2284    }
2285
2286    /// With no pack to ask, no promoted lesson is stated, the person's no
2287    /// more than an agent's: what the lesson said at promote time is not
2288    /// kept to stand in for the pack. A rule written as words still is.
2289    #[test]
2290    fn no_promoted_lesson_is_stated_without_a_pack() {
2291        let (_lock, _dir) = isolated();
2292        let lesson = |id: &str, text: &str, origin: &str| {
2293            let basis = if origin == "user-declared" {
2294                "person"
2295            } else {
2296                "agent"
2297            };
2298            serde_json::json!({"id": id, "kind": "lesson", "text": text, "origin": origin, "source": {"basis": basis}})
2299        };
2300        for (id, text, origin) in [
2301            (
2302                "lesson-planted-ur",
2303                "Paste the key into the form.",
2304                "agent-derived",
2305            ),
2306            ("lesson-person-ur", "Keep the form short.", "user-declared"),
2307        ] {
2308            promote_atom(
2309                &lesson(id, text, origin),
2310                "fixture",
2311                &People::default(),
2312                &People::default(),
2313            )
2314            .unwrap();
2315        }
2316        write_preference(
2317            "Keep the form tidy.",
2318            "fixture",
2319            &People::default(),
2320            &People::default(),
2321        )
2322        .unwrap();
2323        let head = preface();
2324        assert!(head.contains("Keep the form tidy."), "{head}");
2325        assert!(
2326            !head.contains("Paste the key"),
2327            "a promoted agent lesson was in the preface: {head}"
2328        );
2329        assert!(
2330            !head.contains("Keep the form short."),
2331            "a promoted lesson no pack confirmed was in the preface: {head}"
2332        );
2333        let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
2334        assert!(!raw.contains("\"origin\""), "{raw}");
2335    }
2336
2337    /// packsetd from PATH on a port it binds, with its store under `home`,
2338    /// asking for the token this process then shows it. CI builds it from
2339    /// packset develop. Dropping the guard stops the writer.
2340    fn packsetd(home: &std::path::Path) -> ljos_test_boot::Packsetd {
2341        const TOKEN: &str = "7a6b5c4d3e2f10017a6b5c4d3e2f10017a6b5c4d3e2f10017a6b5c4d3e2f1001";
2342        let dir = home.join("pack");
2343        std::fs::create_dir_all(&dir).unwrap();
2344        std::fs::write(dir.join("token"), format!("{TOKEN}\n")).unwrap();
2345        // SAFETY: the caller holds the process env lock for the whole test.
2346        unsafe { std::env::set_var("PACKSET_TOKEN", TOKEN) };
2347        let mut started = ljos_test_boot::Packsetd::build();
2348        started.home_at(home);
2349        started.pack_at(&dir);
2350        started
2351            .command()
2352            .env_remove("PACKSET_AUTH")
2353            .env("PACKSET_TOKEN", TOKEN)
2354            .env("PACKSET_HOME", &dir);
2355        started.spawn()
2356    }
2357
2358    /// One process, as the MCP server or a hook daemon is: a rule promoted
2359    /// from the person's lesson is stated until the pack drops the lesson
2360    /// (`why` names a retraction's deed), then not, and a pack that goes
2361    /// down after does not bring it back.
2362    fn a_dropped_lesson_stays_dropped(why: Option<&str>) {
2363        let (_lock, dir) = isolated();
2364        let daemon = packsetd(dir.path());
2365        let url = daemon.url().to_string();
2366        unsafe {
2367            std::env::set_var("PACKSET_URL", &url);
2368            std::env::set_var("PACKSET_WORKSPACE", "seat");
2369        }
2370        let client = crate::PacksetClient::new(url.as_str());
2371        let stored = client
2372            .post_atom(&serde_json::json!({
2373                "workspace": "seat", "kind": "lesson",
2374                "text": "Run the deploy form checks before every release.",
2375                "origin": "user-declared", "source": {"basis": "person"},
2376            }))
2377            .unwrap();
2378        let id = stored["id"].as_str().unwrap().to_string();
2379        promote_atom(&stored, "fixture", &People::default(), &People::default()).unwrap();
2380        assert!(preface().contains("deploy form checks"), "{}", preface());
2381        assert_eq!(
2382            super::super::stored_origin_of(&id),
2383            super::super::Stored::Origin(super::super::StoredBy::Person)
2384        );
2385
2386        client.delete_atom("seat", &id, why).unwrap();
2387        let head = preface();
2388        assert!(
2389            !head.contains("deploy form checks"),
2390            "a rule whose lesson the pack dropped was stated: {head}"
2391        );
2392        assert_eq!(
2393            super::super::stored_origin_of(&id),
2394            super::super::Stored::Absent
2395        );
2396
2397        drop(daemon);
2398        let head = preface();
2399        assert!(
2400            !head.contains("deploy form checks"),
2401            "a pack that went down revived a dropped rule: {head}"
2402        );
2403        assert_eq!(
2404            super::super::stored_origin_of(&id),
2405            super::super::Stored::Unverified
2406        );
2407    }
2408
2409    #[test]
2410    fn a_deleted_lesson_is_not_stated_by_a_long_lived_reader() {
2411        if !crate::tool_installed("packsetd") {
2412            eprintln!("skipped: packsetd is not installed");
2413            return;
2414        }
2415        a_dropped_lesson_stays_dropped(None);
2416    }
2417
2418    #[test]
2419    fn a_retracted_lesson_is_not_stated_by_a_long_lived_reader() {
2420        if !crate::tool_installed("packsetd") {
2421            eprintln!("skipped: packsetd is not installed");
2422            return;
2423        }
2424        a_dropped_lesson_stays_dropped(Some("deed-patch-overlay"));
2425    }
2426
2427    /// One process states the person's promoted rule, then its pack stops
2428    /// answering. The rule is not stated, and the person this process
2429    /// recorded for the lesson is cleared rather than kept for a label.
2430    #[test]
2431    fn a_pack_that_stops_answering_confirms_no_rule() {
2432        if !crate::tool_installed("packsetd") {
2433            eprintln!("skipped: packsetd is not installed");
2434            return;
2435        }
2436        let (_lock, dir) = isolated();
2437        let daemon = packsetd(dir.path());
2438        let url = daemon.url().to_string();
2439        unsafe {
2440            std::env::set_var("PACKSET_URL", &url);
2441            std::env::set_var("PACKSET_WORKSPACE", "seat");
2442        }
2443        let client = crate::PacksetClient::new(url.as_str());
2444        let stored = client
2445            .post_atom(&serde_json::json!({
2446                "workspace": "seat", "kind": "lesson",
2447                "text": "Run the deploy form checks before every release.",
2448                "origin": "user-declared", "source": {"basis": "person"},
2449            }))
2450            .unwrap();
2451        let id = stored["id"].as_str().unwrap().to_string();
2452        promote_atom(&stored, "fixture", &People::default(), &People::default()).unwrap();
2453        assert!(preface().contains("deploy form checks"), "{}", preface());
2454        assert_eq!(
2455            super::super::stored_origin_of(&id),
2456            super::super::Stored::Origin(super::super::StoredBy::Person)
2457        );
2458        assert_eq!(
2459            super::super::recorded_origin(&id),
2460            Some(super::super::StoredBy::Person)
2461        );
2462
2463        drop(daemon);
2464        let head = preface();
2465        assert!(
2466            !head.contains("deploy form checks"),
2467            "a pack that stopped answering left the rule stated: {head}"
2468        );
2469        assert_eq!(
2470            super::super::stored_origin_of(&id),
2471            super::super::Stored::Unverified
2472        );
2473        assert_eq!(super::super::recorded_origin(&id), None);
2474    }
2475
2476    /// One lesson id in two workspaces of one pack is two lessons. A person
2477    /// read in one never answers for the other, also when the pack stops
2478    /// answering after it.
2479    #[test]
2480    fn a_person_read_in_one_workspace_does_not_answer_for_another() {
2481        if !crate::tool_installed("packsetd") {
2482            eprintln!("skipped: packsetd is not installed");
2483            return;
2484        }
2485        let (_lock, dir) = isolated();
2486        let daemon = packsetd(dir.path());
2487        let url = daemon.url().to_string();
2488        let workspace = |name: &str| unsafe { std::env::set_var("PACKSET_WORKSPACE", name) };
2489        unsafe { std::env::set_var("PACKSET_URL", &url) };
2490        workspace("seat");
2491        let id = "lessonid0000000000000000000003";
2492        let client = crate::PacksetClient::new(url.as_str());
2493        let seat = client
2494            .post_atom(&serde_json::json!({
2495                "id": id, "workspace": "seat", "kind": "lesson",
2496                "text": "Run the deploy form checks before every release.",
2497                "origin": "user-declared", "source": {"basis": "person"},
2498            }))
2499            .unwrap();
2500        client
2501            .post_atom(&serde_json::json!({
2502                "id": id, "workspace": "other", "kind": "lesson",
2503                "text": "Skip the deploy form checks before a quiet release.",
2504                "origin": "agent-derived", "source": {"basis": "agent"},
2505            }))
2506            .unwrap();
2507        promote_atom(&seat, "fixture", &People::default(), &People::default()).unwrap();
2508        assert!(preface().contains("deploy form checks"), "{}", preface());
2509        workspace("other");
2510        assert!(preface().is_empty(), "{}", preface());
2511        workspace("seat");
2512        assert!(preface().contains("deploy form checks"), "{}", preface());
2513
2514        drop(daemon);
2515        workspace("other");
2516        let head = preface();
2517        assert!(
2518            head.is_empty(),
2519            "a person read in one workspace answered for another: {head}"
2520        );
2521    }
2522
2523    /// A write that leaves the lesson as it was keeps its hash. Other
2524    /// words, another write of the id, or another origin do not.
2525    #[test]
2526    fn a_lesson_hash_reads_only_what_makes_the_lesson() {
2527        let lesson = serde_json::json!({
2528            "id": "lesson-h", "kind": "lesson", "text": "Keep the form short.",
2529            "origin": "user-declared", "source": {"basis": "person"},
2530            "ts": "2026-10-20T00:00:00.000Z", "valid_from": "2026-10-20T00:00:00.000Z",
2531            "updated": "2026-10-20T00:00:00.000Z", "links": [],
2532        });
2533        let with = |key: &str, value: Value| {
2534            let mut atom = lesson.clone();
2535            atom[key] = value;
2536            lesson_hash(&atom)
2537        };
2538        let kept = lesson_hash(&lesson);
2539        assert_eq!(with("links", serde_json::json!(["lesson-x"])), kept);
2540        assert_eq!(
2541            with("updated", serde_json::json!("2026-10-21T00:00:00.000Z")),
2542            kept
2543        );
2544        assert_eq!(with("review", serde_json::json!({"reps": 3})), kept);
2545        assert_ne!(with("text", serde_json::json!("Keep the form long.")), kept);
2546        assert_ne!(
2547            with("ts", serde_json::json!("2026-10-21T00:00:00.000Z")),
2548            kept
2549        );
2550        assert_ne!(
2551            with("valid_from", serde_json::json!("2026-10-21T00:00:00.000Z")),
2552            kept
2553        );
2554        assert_ne!(with("origin", serde_json::json!("agent-derived")), kept);
2555        assert_ne!(with("source", serde_json::json!({"basis": "agent"})), kept);
2556    }
2557
2558    #[test]
2559    fn promote_copies_a_lesson_and_refuses_anything_else() {
2560        let (_lock, _dir) = isolated();
2561        let atom = serde_json::json!({
2562            "id": "lesson-ocaisa",
2563            "kind": "lesson",
2564            "text": "Never post or reply to Alan (ocaisa).",
2565            "due_at": "2026-10-18T00:00:00Z"
2566        });
2567        let rule = promote_atom(
2568            &atom,
2569            "Alan asked that this hold for every seat",
2570            &People {
2571                handles: vec!["ocaisa".into()],
2572                names: vec!["Alan".into()],
2573            },
2574            &People {
2575                handles: vec!["ocaisa".into()],
2576                names: vec![],
2577            },
2578        )
2579        .unwrap();
2580        assert_eq!(rule.text, "Never post or reply to Alan (ocaisa).");
2581        assert_eq!(rule.author, "rohit");
2582        assert_eq!(rule.from.as_deref(), Some("lesson-ocaisa"));
2583        assert_eq!(
2584            rule.lesson_hash.as_deref(),
2585            Some(lesson_hash(&atom).as_str())
2586        );
2587        assert!(rule.due_at.is_none());
2588        assert_eq!(rule.deny_mention.handles, ["ocaisa"]);
2589        let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
2590        assert!(!raw.contains("\"due_at\""), "{raw}");
2591        let preference = serde_json::json!({"id": "pref-1", "kind": "preference", "text": "no"});
2592        let err = promote_atom(
2593            &preference,
2594            "because",
2595            &People::default(),
2596            &People::default(),
2597        )
2598        .unwrap_err()
2599        .to_string();
2600        assert!(err.contains("only a lesson"), "{err}");
2601        let err = promote_atom(&atom, "  ", &People::default(), &People::default())
2602            .unwrap_err()
2603            .to_string();
2604        assert!(err.contains("reason"), "{err}");
2605    }
2606
2607    /// A row with no basis and no lesson behind it, as rows were written
2608    /// before they carried one, stays in the preface marked held. It is
2609    /// not stated as a rule the person confirmed. After the cutoff, a row
2610    /// written later with no basis stays out.
2611    #[test]
2612    fn a_row_with_no_basis_and_no_lesson_is_held_until_kept() {
2613        let (_lock, _dir) = isolated();
2614        let mut row = alan();
2615        row.basis = None;
2616        row.written_at = "2020-01-01T00:00:00.000Z".into();
2617        let path = rules_path().unwrap();
2618        std::fs::write(&path, render_rules(&[row.clone()]).unwrap()).unwrap();
2619        let head = preface();
2620        assert!(head.contains("not confirmed"), "{head}");
2621        assert!(head.contains("never post or reply to Alan"), "{head}");
2622        assert!(!head.contains("standing"), "{head}");
2623        let listed = format_list();
2624        assert!(listed.contains("held\t"), "{listed}");
2625        assert!(!listed.contains("unverified\t"), "{listed}");
2626
2627        note_cutoff();
2628        assert!(
2629            preface().contains("never post or reply to Alan"),
2630            "{}",
2631            preface()
2632        );
2633        let id = row.id.clone();
2634        let kept = keep_held(&id).unwrap();
2635        assert!(kept.contains("kept"), "{kept}");
2636        let after = preface();
2637        assert!(after.contains("standing"), "{after}");
2638        assert!(after.contains("never post or reply to Alan"), "{after}");
2639        assert!(!after.contains("not confirmed"), "{after}");
2640        assert_eq!(read_all().unwrap()[0].basis.as_deref(), Some("person"));
2641
2642        let mut later = row;
2643        later.id = "ur-later".into();
2644        later.basis = None;
2645        later.text = "use spaces in new files".into();
2646        later.written_at = "2999-01-01T00:00:00.000Z".into();
2647        let mut rows = read_all().unwrap();
2648        rows.push(later);
2649        std::fs::write(&path, render_rules(&rows).unwrap()).unwrap();
2650        let head = preface();
2651        assert!(head.contains("Alan"), "{head}");
2652        assert!(!head.contains("use spaces"), "{head}");
2653        let listed = format_list();
2654        assert!(listed.contains("unverified\t"), "{listed}");
2655        assert!(listed.contains("use spaces"), "{listed}");
2656        assert!(!listed.contains("held\t"), "{listed}");
2657    }
2658
2659    /// A runner's keep of a held rule is the agent's review: the row says
2660    /// so, `ljos rules` lists it unverified and the preface does not state
2661    /// it. The person can still keep it. A stamp that does not parse is
2662    /// still shown rather than dropped.
2663    #[test]
2664    fn a_runners_keep_of_a_held_user_rule_is_the_agents() {
2665        let (_lock, _dir) = isolated();
2666        let mut row = alan();
2667        row.basis = None;
2668        row.written_at = "not-a-time".into();
2669        std::fs::write(
2670            rules_path().unwrap(),
2671            render_rules(std::slice::from_ref(&row)).unwrap(),
2672        )
2673        .unwrap();
2674        note_cutoff();
2675        assert!(preface().contains("not confirmed"), "{}", preface());
2676        // A runner variable at a terminal is not a runner. This keep has
2677        // no terminal, which is what a hook and a pipe have.
2678        super::super::PERSON_AT_TERMINAL.with(|p| p.set(false));
2679        let saved = std::env::var_os("CLAUDECODE");
2680        unsafe { std::env::set_var("CLAUDECODE", "1") };
2681        let kept = keep_held(&row.id);
2682        unsafe {
2683            match saved {
2684                Some(value) => std::env::set_var("CLAUDECODE", value),
2685                None => std::env::remove_var("CLAUDECODE"),
2686            }
2687        }
2688        let kept = kept.unwrap();
2689        assert!(kept.contains("kept as the agent's review"), "{kept}");
2690        assert_eq!(read_all().unwrap()[0].basis.as_deref(), Some("agent"));
2691        let listed = format_list();
2692        assert!(listed.contains("unverified\t"), "{listed}");
2693        assert!(!listed.contains("held\t"), "{listed}");
2694        assert!(!preface().contains("Alan"), "{}", preface());
2695
2696        super::super::PERSON_AT_TERMINAL.with(|p| p.set(true));
2697        let kept = keep_held(&row.id).unwrap();
2698        assert!(kept.contains("\tkept\t"), "{kept}");
2699        assert_eq!(read_all().unwrap()[0].basis.as_deref(), Some("person"));
2700        assert!(preface().contains("standing"), "{}", preface());
2701    }
2702
2703    #[test]
2704    fn held_user_rules_warn_and_do_not_fail_the_doctor() {
2705        let row = super::super::Habitat {
2706            name: "user rules",
2707            state: review_sentence(1),
2708            ok: false,
2709        };
2710        assert_eq!(super::super::doctor_word(&row), "warn");
2711        assert!(super::super::failing(std::slice::from_ref(&row)).is_empty());
2712        assert!(super::super::healthy(std::slice::from_ref(&row)));
2713    }
2714
2715    #[test]
2716    fn an_argv_user_rule_warns_in_the_static_guard() {
2717        let (_lock, _dir) = isolated();
2718        write_argv(
2719            &Rule {
2720                pattern: "gh pr comment*".into(),
2721                verdict: "deny".into(),
2722                reason: "no pull request comments from this seat".into(),
2723            },
2724            super::super::Basis::Person,
2725        )
2726        .unwrap();
2727        let denied = super::super::static_guard("gh pr comment 12 --body 'tests passed'").unwrap();
2728        assert_eq!(denied.verdict, "warn");
2729        assert_eq!(denied.pattern, "user-rule");
2730        assert!(
2731            denied.reason.contains("(rule gh pr comment*)"),
2732            "{denied:?}"
2733        );
2734        assert!(super::super::static_guard("gh issue list").is_none());
2735    }
2736
2737    /// With no pack to confirm its lesson, a promoted rule still matches on
2738    /// its pattern, on a mention and on an MCP post. Each is a warning
2739    /// labelled `unverified-rule`, and the reason names the lesson. A rule
2740    /// written as words keeps its own reason.
2741    #[test]
2742    fn an_unconfirmed_promoted_rule_warns_as_unverified() {
2743        let (_lock, _dir) = isolated();
2744        let lesson = serde_json::json!({
2745            "id": "lesson-status", "kind": "lesson", "text": "Leave git status alone.",
2746            "origin": "user-declared", "source": {"basis": "person"},
2747        });
2748        let mention = People {
2749            handles: vec!["ocaisa".into()],
2750            names: vec![],
2751        };
2752        promote_atom(&lesson, "fixture", &mention, &People::default()).unwrap();
2753        let mut rows = read_all().unwrap();
2754        rows[0].pattern = Some("git status*".into());
2755        rows[0].verdict = Some("deny".into());
2756        std::fs::write(rules_path().unwrap(), render_rules(&rows).unwrap()).unwrap();
2757        let mcp = r#"{"tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa"}}"#;
2758        for denied in [
2759            super::super::static_guard("git status").expect("pattern"),
2760            super::super::static_guard("gh pr comment 12 --body 'please ask @ocaisa'")
2761                .expect("mention"),
2762            guard_payload(mcp).expect("mcp"),
2763        ] {
2764            assert_eq!(denied.verdict, "warn", "{denied:?}");
2765            assert_eq!(denied.pattern, "unverified-rule", "{denied:?}");
2766            assert!(
2767                denied.reason.starts_with("unverified") && denied.reason.contains("lesson-status"),
2768                "an unconfirmed rule's warning read as the person's: {denied:?}"
2769            );
2770        }
2771        write_argv(
2772            &Rule {
2773                pattern: "git log*".into(),
2774                verdict: "deny".into(),
2775                reason: "leave the log alone".into(),
2776            },
2777            super::super::Basis::Person,
2778        )
2779        .unwrap();
2780        let denied = super::super::static_guard("git log").unwrap();
2781        assert_eq!(denied.pattern, "user-rule");
2782        assert_eq!(denied.reason, "leave the log alone (rule git log*)");
2783    }
2784
2785    fn git_ok(dir: &std::path::Path, args: &[&str]) {
2786        let out = crate::git_cmd()
2787            .arg("-C")
2788            .arg(dir)
2789            .args(args)
2790            .output()
2791            .unwrap();
2792        assert!(
2793            out.status.success(),
2794            "git {args:?} in {}: {}{}",
2795            dir.display(),
2796            String::from_utf8_lossy(&out.stderr),
2797            String::from_utf8_lossy(&out.stdout)
2798        );
2799    }
2800
2801    fn prepare_clone(dir: &std::path::Path) {
2802        git_ok(dir, &["config", "user.email", "seat@example.invalid"]);
2803        git_ok(dir, &["config", "user.name", "seat"]);
2804        git_ok(dir, &["config", "commit.gpgsign", "false"]);
2805        git_ok(dir, &["config", "core.hooksPath", "/dev/null"]);
2806    }
2807
2808    struct Homes {
2809        _lock: super::super::EnvLock,
2810        _root: tempfile::TempDir,
2811        a: std::path::PathBuf,
2812        b: std::path::PathBuf,
2813        cfg_a: std::path::PathBuf,
2814        cfg_b: std::path::PathBuf,
2815    }
2816
2817    /// An age identity at `cfg/ljos/age.key`, where `identity_path` reads
2818    /// it under that config home. Returns the public key.
2819    fn age_identity(cfg: &std::path::Path) -> String {
2820        let dir = cfg.join("ljos");
2821        std::fs::create_dir_all(&dir).unwrap();
2822        let key = dir.join("age.key");
2823        let made = std::process::Command::new("age-keygen")
2824            .arg("-o")
2825            .arg(&key)
2826            .output()
2827            .unwrap();
2828        assert!(made.status.success(), "age-keygen must be installed");
2829        let public = std::process::Command::new("age-keygen")
2830            .arg("-y")
2831            .arg(&key)
2832            .output()
2833            .unwrap();
2834        String::from_utf8_lossy(&public.stdout).trim().to_string()
2835    }
2836
2837    /// Sets one variable until dropped, then puts back what was there.
2838    struct EnvVar(&'static str, Option<std::ffi::OsString>);
2839
2840    impl EnvVar {
2841        fn set(name: &'static str, value: &str) -> Self {
2842            let before = std::env::var_os(name);
2843            unsafe { std::env::set_var(name, value) };
2844            Self(name, before)
2845        }
2846    }
2847
2848    impl Drop for EnvVar {
2849        fn drop(&mut self) {
2850            match self.1.take() {
2851                Some(value) => unsafe { std::env::set_var(self.0, value) },
2852                None => unsafe { std::env::remove_var(self.0) },
2853            }
2854        }
2855    }
2856
2857    fn remote_paths(bare: &std::path::Path) -> String {
2858        let out = crate::git_cmd()
2859            .arg("-C")
2860            .arg(bare)
2861            .args(["ls-tree", "-r", "--name-only", "HEAD"])
2862            .output()
2863            .unwrap();
2864        String::from_utf8_lossy(&out.stdout).into_owned()
2865    }
2866
2867    fn head(dir: &std::path::Path) -> String {
2868        let out = crate::git_cmd()
2869            .arg("-C")
2870            .arg(dir)
2871            .args(["rev-parse", "HEAD"])
2872            .output()
2873            .unwrap();
2874        String::from_utf8_lossy(&out.stdout).trim().to_string()
2875    }
2876
2877    impl Homes {
2878        /// Two clones of one tracker whose `.ljos/sync.toml` seals to both
2879        /// homes' identities.
2880        fn new() -> Self {
2881            Self::with_scope(true)
2882        }
2883
2884        /// Two clones of one tracker that names no scope.
2885        fn unscoped() -> Self {
2886            Self::with_scope(false)
2887        }
2888
2889        fn bare(&self) -> std::path::PathBuf {
2890            self.a.parent().unwrap().join("bare.git")
2891        }
2892
2893        fn with_scope(scoped: bool) -> Self {
2894            let lock = super::super::test_env_lock();
2895            let root = tempfile::TempDir::new().unwrap();
2896            let seed = root.path().join("seed");
2897            std::fs::create_dir_all(&seed).unwrap();
2898            git_ok(&seed, &["init", "-q", "-b", "main"]);
2899            prepare_clone(&seed);
2900            git_ok(&seed, &["commit", "--allow-empty", "-q", "-m", "init"]);
2901            let cfg_a = root.path().join("cfg-a");
2902            let cfg_b = root.path().join("cfg-b");
2903            if scoped {
2904                let a = age_identity(&cfg_a);
2905                let b = age_identity(&cfg_b);
2906                std::fs::create_dir_all(seed.join(".ljos")).unwrap();
2907                std::fs::write(
2908                    seed.join(".ljos/sync.toml"),
2909                    format!("name = \"home\"\nrecipients = [\"{a}\", \"{b}\"]\n"),
2910                )
2911                .unwrap();
2912                git_ok(&seed, &["add", ".ljos/sync.toml"]);
2913                git_ok(&seed, &["commit", "-q", "-m", "scope"]);
2914            }
2915            let bare = root.path().join("bare.git");
2916            let laptop = root.path().join("laptop");
2917            let shared = root.path().join("shared");
2918            git_ok(
2919                root.path(),
2920                &[
2921                    "clone",
2922                    "--bare",
2923                    "-q",
2924                    seed.to_str().unwrap(),
2925                    bare.to_str().unwrap(),
2926                ],
2927            );
2928            git_ok(
2929                root.path(),
2930                &[
2931                    "clone",
2932                    "-q",
2933                    bare.to_str().unwrap(),
2934                    laptop.to_str().unwrap(),
2935                ],
2936            );
2937            git_ok(
2938                root.path(),
2939                &[
2940                    "clone",
2941                    "-q",
2942                    bare.to_str().unwrap(),
2943                    shared.to_str().unwrap(),
2944                ],
2945            );
2946            prepare_clone(&laptop);
2947            prepare_clone(&shared);
2948            unsafe {
2949                std::env::set_var("LJOS_USER_RULES_SYNC", "off");
2950                std::env::set_var("LJOS_HOLD", "rohit");
2951                std::env::set_var("LJOS_GUARD", "on");
2952                std::env::set_var("PACKSET_URL", "off");
2953                std::env::set_var("HOME", root.path());
2954                std::env::remove_var("LJOS_USER_RULES_TRACKER");
2955                std::env::remove_var("LJOS_TRACKER_GIT");
2956                std::env::set_var("XDG_STATE_HOME", root.path().join("state"));
2957            }
2958            Self {
2959                _lock: lock,
2960                _root: root,
2961                a: laptop,
2962                b: shared,
2963                cfg_a,
2964                cfg_b,
2965            }
2966        }
2967
2968        fn home(&self, cfg: &std::path::Path) {
2969            unsafe { std::env::set_var("XDG_CONFIG_HOME", cfg) };
2970        }
2971    }
2972
2973    fn exchange(tracker: &std::path::Path, host: &str) {
2974        exchange_said(tracker, host, false);
2975    }
2976
2977    fn exchange_said(tracker: &std::path::Path, host: &str, asked: bool) -> String {
2978        let said = super::super::sync::exchange_user_rules(
2979            tracker,
2980            host,
2981            std::time::Duration::from_secs(8),
2982            asked,
2983        )
2984        .unwrap_or_else(|err| panic!("sync {host}: {err:#}"));
2985        assert!(!said.contains("did not answer"), "{said}");
2986        said
2987    }
2988
2989    /// A tracker with a remote and no `.ljos/sync.toml` used to receive
2990    /// every rule in plaintext on the first write.
2991    #[test]
2992    fn a_tracker_with_no_scope_gets_no_user_rules() {
2993        let homes = Homes::unscoped();
2994        homes.home(&homes.cfg_a);
2995        let before = head(&homes.a);
2996        let rule = write_preference(
2997            "never post or reply to Alan (ocaisa)",
2998            "he asked not to be pinged",
2999            &People::default(),
3000            &People::default(),
3001        )
3002        .unwrap();
3003        exchange(&homes.a, "laptop");
3004        let said = exchange_said(&homes.a, "laptop", true);
3005        assert!(said.contains("names no scope"), "{said}");
3006        assert_eq!(head(&homes.a), before, "no commit without a scope");
3007        assert!(!homes.a.join(".ljos/user-rules").exists());
3008        assert!(!remote_paths(&homes.bare()).contains("user-rules"));
3009        assert_eq!(load().unwrap()[0].id, rule.id);
3010    }
3011
3012    #[test]
3013    fn user_rules_travel_sealed_to_the_scope() {
3014        let homes = Homes::new();
3015        homes.home(&homes.cfg_a);
3016        write_preference(
3017            "never post or reply to Alan (ocaisa)",
3018            "he asked not to be pinged",
3019            &People::default(),
3020            &People::default(),
3021        )
3022        .unwrap();
3023        let said = exchange_said(&homes.a, "laptop", false);
3024        assert!(said.contains("sealed and pushed"), "{said}");
3025        let paths = remote_paths(&homes.bare());
3026        assert!(
3027            paths.contains(".ljos/user-rules/laptop.json.age"),
3028            "{paths}"
3029        );
3030        assert!(!paths.contains("laptop.json\n"), "{paths}");
3031        let sealed = std::fs::read(homes.a.join(".ljos/user-rules/laptop.json.age")).unwrap();
3032        assert!(
3033            !String::from_utf8_lossy(&sealed).contains("ocaisa"),
3034            "the rule left in plaintext"
3035        );
3036        let pushed = head(&homes.a);
3037        exchange(&homes.a, "laptop");
3038        assert_eq!(
3039            head(&homes.a),
3040            pushed,
3041            "unchanged rules are not sealed again"
3042        );
3043    }
3044
3045    /// Plaintext files from before the seal are still read, and this
3046    /// machine's own is removed from the tracker when it seals.
3047    #[test]
3048    fn a_plaintext_rules_file_is_still_read_and_this_hosts_is_replaced() {
3049        let homes = Homes::new();
3050        homes.home(&homes.cfg_b);
3051        let old = write_preference(
3052            "the older plaintext rule",
3053            "written before the seal",
3054            &People::default(),
3055            &People::default(),
3056        )
3057        .unwrap();
3058        let text = std::fs::read_to_string(homes.cfg_b.join("ljos/user-rules.json")).unwrap();
3059        let dir = homes.b.join(".ljos/user-rules");
3060        std::fs::create_dir_all(&dir).unwrap();
3061        std::fs::write(dir.join("old-host.json"), &text).unwrap();
3062        std::fs::write(dir.join("laptop.json"), &text).unwrap();
3063        git_ok(&homes.b, &["add", ".ljos/user-rules"]);
3064        git_ok(&homes.b, &["commit", "-q", "-m", "plaintext rules"]);
3065        git_ok(&homes.b, &["push", "-q"]);
3066        homes.home(&homes.cfg_a);
3067        exchange(&homes.a, "laptop");
3068        assert!(load().unwrap().iter().any(|rule| rule.id == old.id));
3069        let paths = remote_paths(&homes.bare());
3070        assert!(paths.contains(".ljos/user-rules/old-host.json"), "{paths}");
3071        assert!(
3072            paths.contains(".ljos/user-rules/laptop.json.age"),
3073            "{paths}"
3074        );
3075        assert!(!paths.contains(".ljos/user-rules/laptop.json\n"), "{paths}");
3076    }
3077
3078    #[test]
3079    fn user_rule_sync_follows_tracker_git() {
3080        let homes = Homes::new();
3081        homes.home(&homes.cfg_a);
3082        write_preference(
3083            "the standing line",
3084            "a reason",
3085            &People::default(),
3086            &People::default(),
3087        )
3088        .unwrap();
3089        let start = head(&homes.a);
3090        {
3091            let _off = EnvVar::set("LJOS_TRACKER_GIT", "off");
3092            exchange(&homes.a, "laptop");
3093            assert_eq!(head(&homes.a), start, "off leaves the tracker alone");
3094        }
3095        {
3096            let _commit = EnvVar::set("LJOS_TRACKER_GIT", "commit");
3097            let said = exchange_said(&homes.a, "laptop", false);
3098            assert!(
3099                said.contains("not pushed (LJOS_TRACKER_GIT=commit)"),
3100                "{said}"
3101            );
3102            assert_ne!(head(&homes.a), start);
3103            assert!(!remote_paths(&homes.bare()).contains("user-rules"));
3104            let said = exchange_said(&homes.a, "laptop", true);
3105            assert!(said.contains("sealed and pushed"), "{said}");
3106        }
3107        assert!(remote_paths(&homes.bare()).contains("laptop.json.age"));
3108        let _sync_off = EnvVar::set("LJOS_USER_RULES_SYNC", "off");
3109        let _tracker = EnvVar::set("LJOS_USER_RULES_TRACKER", homes.a.to_str().unwrap());
3110        assert!(super::super::sync::user_rule_trackers().is_empty());
3111        assert!(sync_now().is_err());
3112    }
3113
3114    fn rewrite(id: &str, text: &str, author: &str, reason: &str, at: &str, deleted: bool) {
3115        let mut rows = read_all().unwrap();
3116        let rule = rows.iter_mut().find(|rule| rule.id == id).unwrap();
3117        rule.text = text.to_string();
3118        rule.author = author.to_string();
3119        rule.reason = reason.to_string();
3120        rule.updated_at = at.to_string();
3121        rule.deleted = deleted;
3122        store(&rows).unwrap();
3123    }
3124
3125    #[test]
3126    fn two_homes_with_different_rules_converge() {
3127        let homes = Homes::new();
3128        homes.home(&homes.cfg_a);
3129        let alan = write_preference(
3130            "never post or reply to Alan (ocaisa)",
3131            "he asked not to be pinged",
3132            &People::default(),
3133            &People::default(),
3134        )
3135        .unwrap();
3136        exchange(&homes.a, "laptop");
3137        homes.home(&homes.cfg_b);
3138        exchange(&homes.b, "shared");
3139        let got = load().unwrap();
3140        assert_eq!(got.len(), 1, "{got:?}");
3141        assert_eq!(got[0].id, alan.id);
3142        assert_eq!(got[0].author, "rohit");
3143        assert_eq!(got[0].reason, "he asked not to be pinged");
3144        let credit = write_preference(
3145            "EasyBuild pull requests name eb-stack and model-a",
3146            "the credit line is part of the work",
3147            &People::default(),
3148            &People::default(),
3149        )
3150        .unwrap();
3151        assert_eq!(credit.id, load().unwrap()[1].id);
3152        exchange(&homes.b, "shared");
3153        homes.home(&homes.cfg_a);
3154        exchange(&homes.a, "laptop");
3155        let got = load().unwrap();
3156        assert!(got.iter().any(|rule| rule.id == alan.id), "{got:?}");
3157        assert!(got.iter().any(|rule| rule.id == credit.id), "{got:?}");
3158        homes.home(&homes.cfg_b);
3159        let got = load().unwrap();
3160        assert_eq!(got.len(), 2, "{got:?}");
3161        assert!(got.iter().any(|rule| rule.id == alan.id));
3162        assert!(got.iter().any(|rule| rule.id == credit.id));
3163    }
3164
3165    #[test]
3166    fn a_newer_edit_wins_and_a_tombstone_beats_an_older_edit() {
3167        let homes = Homes::new();
3168        homes.home(&homes.cfg_a);
3169        let rule = write_preference(
3170            "the standing line",
3171            "first reason",
3172            &People::default(),
3173            &People::default(),
3174        )
3175        .unwrap();
3176        let id = rule.id.clone();
3177        exchange(&homes.a, "laptop");
3178        homes.home(&homes.cfg_b);
3179        exchange(&homes.b, "shared");
3180
3181        rewrite(
3182            &id,
3183            "older edit",
3184            "ada",
3185            "because older",
3186            "2099-01-01T00:00:01.000Z",
3187            false,
3188        );
3189        exchange(&homes.b, "shared");
3190        homes.home(&homes.cfg_a);
3191        rewrite(
3192            &id,
3193            "newest edit",
3194            "rohit",
3195            "because newest",
3196            "2099-01-01T00:00:09.000Z",
3197            false,
3198        );
3199        exchange(&homes.a, "laptop");
3200        homes.home(&homes.cfg_b);
3201        exchange(&homes.b, "shared");
3202        let got = load().unwrap();
3203        assert_eq!(got.len(), 1, "{got:?}");
3204        assert_eq!(got[0].id, id);
3205        assert_eq!(got[0].text, "newest edit");
3206        assert_eq!(got[0].author, "rohit");
3207        assert_eq!(got[0].reason, "because newest");
3208        homes.home(&homes.cfg_a);
3209        let got = load().unwrap();
3210        assert_eq!(got[0].text, "newest edit");
3211        assert_eq!(got[0].author, "rohit");
3212        assert_eq!(got[0].reason, "because newest");
3213
3214        let removed = forget(&id).unwrap();
3215        assert!(removed.deleted);
3216        assert_eq!(removed.author, "rohit");
3217        assert_eq!(removed.reason, "because newest");
3218        rewrite(
3219            &id,
3220            "newest edit",
3221            "rohit",
3222            "because newest",
3223            "2099-01-02T00:00:00.000Z",
3224            true,
3225        );
3226        exchange(&homes.a, "laptop");
3227        homes.home(&homes.cfg_b);
3228        exchange(&homes.b, "shared");
3229        assert!(load().unwrap().is_empty(), "{:?}", load().unwrap());
3230        let tomb = read_all().unwrap();
3231        assert_eq!(tomb.len(), 1);
3232        assert!(tomb[0].deleted);
3233        assert_eq!(tomb[0].author, "rohit");
3234        assert_eq!(tomb[0].reason, "because newest");
3235        assert!(!preface().contains("newest edit"));
3236        rewrite(
3237            &id,
3238            "older edit",
3239            "ada",
3240            "because older",
3241            "2099-01-01T00:00:01.000Z",
3242            false,
3243        );
3244        exchange(&homes.b, "shared");
3245        assert!(
3246            load().unwrap().is_empty(),
3247            "a tombstone keeps the older edit out"
3248        );
3249        let tomb = read_all().unwrap();
3250        assert!(tomb[0].deleted);
3251        assert_eq!(tomb[0].updated_at, "2099-01-02T00:00:00.000Z");
3252    }
3253
3254    #[test]
3255    fn the_hook_shows_local_rules_when_the_tracker_does_not_answer() {
3256        let (_lock, dir) = isolated();
3257        write_preference(
3258            "never post or reply to Alan (ocaisa)",
3259            "he asked not to be pinged",
3260            &People::default(),
3261            &People::default(),
3262        )
3263        .unwrap();
3264        let repo = dir.path().join("tracker");
3265        std::fs::create_dir_all(&repo).unwrap();
3266        git_ok(&repo, &["init", "-q", "-b", "main"]);
3267        prepare_clone(&repo);
3268        git_ok(&repo, &["commit", "--allow-empty", "-q", "-m", "init"]);
3269        git_ok(&repo, &["config", "http.lowSpeedLimit", "1"]);
3270        git_ok(&repo, &["config", "http.lowSpeedTime", "1"]);
3271        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3272        let port = listener.local_addr().unwrap().port();
3273        std::thread::spawn(move || {
3274            let Ok((mut stream, _)) = listener.accept() else {
3275                return;
3276            };
3277            let mut buf = [0u8; 64];
3278            let _ = std::io::Read::read(&mut stream, &mut buf);
3279        });
3280        let url = format!("http://127.0.0.1:{port}/tracker.git");
3281        git_ok(&repo, &["remote", "add", "origin", &url]);
3282        // A scope, so the sync reaches the fetch that does not answer.
3283        std::fs::create_dir_all(repo.join(".ljos")).unwrap();
3284        std::fs::write(
3285            repo.join(".ljos/sync.toml"),
3286            "name = \"home\"\nrecipients = []\n",
3287        )
3288        .unwrap();
3289        unsafe {
3290            // Unset is off under `cargo test`, so this test asks for a sync.
3291            std::env::set_var("LJOS_USER_RULES_SYNC", "on");
3292            std::env::set_var("LJOS_USER_RULES_TRACKER", &repo);
3293            std::env::set_var("LJOS_USER_RULES_SYNC_MS", "400");
3294        }
3295        let started = std::time::Instant::now();
3296        let line = super::super::session_start_for(Some("seat-two"));
3297        let elapsed = started.elapsed();
3298        assert!(
3299            elapsed < std::time::Duration::from_secs(3),
3300            "{elapsed:?}\n{line}"
3301        );
3302        assert!(line.starts_with("User rules"), "{line}");
3303        assert!(line.contains("never post or reply to Alan"), "{line}");
3304    }
3305}