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