Skip to main content

ljos_cli/
capture.rs

1//! A standing choice the person states in chat.
2//!
3//! "From now on always use bun, never yarn" is the person telling the seat
4//! something to keep. The prompt hook saves that sentence, in the person's
5//! words, as a standing preference, and says so in one line with the id
6//! that takes it back. The person wrote it, so it stands at once: there is
7//! no review to wait for.
8//!
9//! An agent's `ljos remember` of words the person typed in their latest
10//! prompt is the same case, so [`said_in_latest_prompt`] lets it stand.
11//!
12//! `LJOS_CAPTURE` picks what happens: `save` (the default), `nudge` (one
13//! line asking the agent to write it), or `off`.
14
15use serde_json::Value;
16
17use super::{
18    add_entities, admit, atom_body, mark_seen, pack, pasted_or_quoted, runtime_dir, seen_ids,
19    stamp_horizon, with_pack_timeout, HookCall,
20};
21
22/// What the prompt hook does with a standing choice it reads.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum CaptureMode {
25    /// Write it as the person's preference and say so.
26    Save,
27    /// Ask the agent, in one line, to write it.
28    Nudge,
29    /// Leave it.
30    Off,
31}
32
33/// The mode `LJOS_CAPTURE` names. Unset or unknown is [`CaptureMode::Save`].
34#[must_use]
35pub fn capture_mode() -> CaptureMode {
36    match std::env::var("LJOS_CAPTURE")
37        .unwrap_or_default()
38        .trim()
39        .to_ascii_lowercase()
40        .as_str()
41    {
42        "nudge" => CaptureMode::Nudge,
43        "off" | "0" | "no" | "false" => CaptureMode::Off,
44        _ => CaptureMode::Save,
45    }
46}
47
48/// A standing choice read from a prompt: what kind of claim it is and the
49/// person's sentence.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Stated {
52    /// `preference`, or `lesson` for "remember that ...".
53    pub kind: &'static str,
54    pub text: String,
55}
56
57/// The longest sentence read as a standing choice. A longer one is a
58/// paragraph of context, not a rule.
59pub const STATED_MAX_CHARS: usize = 200;
60
61/// Openings that state a standing choice when a clause starts with them.
62const LEADS: &[&str] = &[
63    "from now on",
64    "from here on",
65    "henceforth",
66    "always ",
67    "never ",
68    "remember that ",
69    "remember to ",
70    "do not ever ",
71    "don't ever ",
72    "dont ever ",
73    "make sure to always ",
74    "make sure you always ",
75    "make sure you never ",
76    "i prefer ",
77    "we prefer ",
78    "we always ",
79    "we never ",
80    "stop using ",
81];
82
83/// Openings that state a standing choice when a comma follows them:
84/// "going forward, ..." but not "going forward is ...".
85const COMMA_LEADS: &[&str] = &["going forward,", "in future,", "in the future,"];
86
87/// Phrases that make a sentence standing wherever they sit in it.
88const ANYWHERE: &[&str] = &["from now on", "from here on", "henceforth"];
89
90/// Phrases that make a sentence standing when they end it: "use bun going
91/// forward".
92const ENDINGS: &[&str] = &["from now on", "going forward", "in future", "from here on"];
93
94/// Words after "always" or "never" that make it a remark, not a rule:
95/// "never mind", "always the same", "never seen this".
96const NOT_A_RULE: &[&str] = &[
97    "mind", "again", "the", "a", "an", "been", "seen", "heard", "was", "were", "is", "are", "had",
98    "did", "worked", "works", "work", "happened", "happens", "thought", "knew", "got", "gets",
99    "fails", "failed", "breaks", "broke", "on", "in", "at", "for", "with", "by", "when", "since",
100    "before", "after", "really", "quite", "sure", "ever", "once", "seems", "seemed",
101];
102
103/// Words that open a sentence and some filler before it.
104const FILLER: &[&str] = &[
105    "ok", "okay", "so", "also", "and", "btw", "oh", "hey", "note", "fyi", "please", "but",
106];
107
108/// Openings of a question that the person did not end with `?`.
109const QUESTION_OPENINGS: &[&str] = &[
110    "what", "why", "how", "can", "could", "should", "would", "do", "does", "did", "is", "are",
111    "will", "which", "who", "where", "when", "was", "were", "shall",
112];
113
114/// The prompt's sentences: lines split after `.`, `!` and `?` that a space
115/// or the line's end follows. `Cargo.toml` and `e.g.` stay whole.
116fn sentences(prompt: &str) -> Vec<String> {
117    let mut out = Vec::new();
118    for line in prompt.lines() {
119        let mut cur = String::new();
120        let chars: Vec<char> = line.chars().collect();
121        for (i, c) in chars.iter().enumerate() {
122            cur.push(*c);
123            let ends =
124                matches!(c, '.' | '!' | '?') && chars.get(i + 1).is_none_or(|n| n.is_whitespace());
125            if ends {
126                let s = cur.trim().to_string();
127                if !s.is_empty() {
128                    out.push(s);
129                }
130                cur.clear();
131            }
132        }
133        let s = cur.trim().to_string();
134        if !s.is_empty() {
135            out.push(s);
136        }
137    }
138    out
139}
140
141/// `text` with every backtick span blanked, so a lead inside code
142/// (`` `--always-retry` ``) does not count.
143fn outside_code(text: &str) -> String {
144    let mut out = String::with_capacity(text.len());
145    let mut inside = false;
146    for c in text.chars() {
147        if c == '`' {
148            inside = !inside;
149            out.push(' ');
150        } else if inside {
151            out.push('x');
152        } else {
153            out.push(c);
154        }
155    }
156    out
157}
158
159/// The sentence with filler words at its front taken off: "ok, from now
160/// on ..." reads as "from now on ...".
161fn strip_filler(s: &str) -> &str {
162    let mut rest = s.trim_start();
163    loop {
164        let word_end = rest
165            .find(|c: char| !c.is_alphanumeric() && c != '\'')
166            .unwrap_or(rest.len());
167        let word = rest[..word_end].to_ascii_lowercase();
168        if word.is_empty() || !FILLER.contains(&word.as_str()) {
169            return rest;
170        }
171        let after = &rest[word_end..];
172        let after = after.trim_start_matches(|c: char| c == ',' || c == ':' || c.is_whitespace());
173        if after.len() == rest.len() || after.is_empty() {
174            return rest;
175        }
176        rest = after;
177    }
178}
179
180/// Whether one clause, lowercased, opens with a standing lead.
181fn clause_leads(clause: &str) -> bool {
182    let c = clause.trim_start();
183    LEADS.iter().any(|lead| {
184        let Some(rest) = c.strip_prefix(lead) else {
185            return false;
186        };
187        if matches!(*lead, "always " | "never ") {
188            let next = rest
189                .split(|ch: char| !ch.is_alphanumeric() && ch != '\'')
190                .next()
191                .unwrap_or("");
192            return !next.is_empty() && !NOT_A_RULE.contains(&next);
193        }
194        true
195    })
196}
197
198/// Whether a sentence, as typed, states a standing choice.
199fn states_a_rule(sentence: &str) -> bool {
200    let words = sentence.split_whitespace().count();
201    if !(3..=40).contains(&words) || sentence.chars().count() > STATED_MAX_CHARS {
202        return false;
203    }
204    if sentence.trim_end().ends_with('?') {
205        return false;
206    }
207    let plain = outside_code(strip_filler(sentence)).to_lowercase();
208    let first = plain
209        .split(|c: char| !c.is_alphanumeric() && c != '\'')
210        .next()
211        .unwrap_or("");
212    if QUESTION_OPENINGS.contains(&first) || first == "you" {
213        return false;
214    }
215    let bare = plain.trim_end_matches(|c: char| !c.is_alphanumeric());
216    if ANYWHERE.iter().any(|p| plain.contains(p))
217        || ENDINGS.iter().any(|p| bare.ends_with(p))
218        || COMMA_LEADS
219            .iter()
220            .any(|p| plain.trim_start().starts_with(p))
221    {
222        return true;
223    }
224    plain.split([',', ';']).any(clause_leads)
225}
226
227/// The words saved for a stated choice: the sentence with its filler
228/// taken off, capitalized. "Remember that X" saves X as a lesson.
229fn saved_words(sentence: &str) -> Stated {
230    let s = strip_filler(sentence).trim();
231    let lower = s.to_lowercase();
232    let (kind, body) = match lower.strip_prefix("remember that ") {
233        Some(_) => ("lesson", &s["remember that ".len()..]),
234        None => ("preference", s),
235    };
236    let mut chars = body.trim().chars();
237    let text = match chars.next() {
238        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
239        None => String::new(),
240    };
241    Stated { kind, text }
242}
243
244/// The standing choice a prompt states, if it states one: first-person or
245/// imperative phrasing such as "from now on", "always", "never", "going
246/// forward" or "remember that", outside code and quotes, in a sentence of
247/// at most [`STATED_MAX_CHARS`] characters. A question, pasted output and
248/// a quoted rule state nothing.
249#[must_use]
250pub fn stated_preference(prompt: &str) -> Option<Stated> {
251    if pasted_or_quoted(prompt) {
252        return None;
253    }
254    sentences(prompt)
255        .into_iter()
256        .find(|s| states_a_rule(s))
257        .map(|s| saved_words(&s))
258        .filter(|s| s.text.split_whitespace().count() >= 2)
259}
260
261/// Whether `LJOS_UNATTENDED` says no person is at this session: `1`,
262/// `true`, `yes` or `on`. An overnight runner or a script driving an agent
263/// sets it, and its prompts are then nobody's stated choice.
264#[must_use]
265pub fn unattended() -> bool {
266    std::env::var("LJOS_UNATTENDED").is_ok_and(|v| {
267        matches!(
268            v.trim().to_ascii_lowercase().as_str(),
269            "1" | "true" | "yes" | "on"
270        )
271    })
272}
273
274/// The thread a Codex rollout file is for, from a name such as
275/// `rollout-2026-10-11T09-30-00-<uuid>.jsonl`, or `None` for any other
276/// name.
277fn rollout_thread(path: &str) -> Option<String> {
278    let name = path.rsplit(['/', '\\']).next()?;
279    let rest = name.strip_prefix("rollout-")?;
280    let stem = &rest[..rest.find(".jsonl")?];
281    // The stamp is `YYYY-MM-DDTHH-MM-SS-`, twenty characters.
282    let id = stem.get(20..56)?;
283    let tail = &stem[56..];
284    let uuid_shape = id.char_indices().all(|(i, c)| {
285        if matches!(i, 8 | 13 | 18 | 23) {
286            c == '-'
287        } else {
288            c.is_ascii_hexdigit()
289        }
290    });
291    (uuid_shape && (tail.is_empty() || tail.starts_with('_'))).then(|| id.to_ascii_lowercase())
292}
293
294/// Why a prompt event does not carry the person's words, or `None` when
295/// it does. A prompt event is not always typed by a person: Codex fires it
296/// inside a subagent, with the brief the parent agent wrote as `prompt`.
297/// A spawned agent's payload names it with `agent_id`, as Claude Code's
298/// does inside a subagent, and Codex's own review thread names a rollout
299/// file for a thread other than the session's. [`unattended`] covers a
300/// session with nobody there at all.
301#[must_use]
302pub fn not_the_person(input: &str) -> Option<&'static str> {
303    if unattended() {
304        return Some("LJOS_UNATTENDED is set");
305    }
306    let v: Value = serde_json::from_str(input.trim()).ok()?;
307    let named = |key: &str| {
308        v.get(key)
309            .and_then(Value::as_str)
310            .is_some_and(|s| !s.trim().is_empty())
311    };
312    if named("agent_id") || named("agentId") {
313        return Some("a subagent's brief");
314    }
315    let session = v
316        .get("session_id")
317        .and_then(Value::as_str)
318        .map(|s| s.trim().to_ascii_lowercase())
319        .filter(|s| !s.is_empty());
320    let thread = v
321        .get("transcript_path")
322        .and_then(Value::as_str)
323        .and_then(rollout_thread);
324    match (session, thread) {
325        (Some(session), Some(thread)) if session != thread => Some("another thread's prompt"),
326        _ => None,
327    }
328}
329
330/// How long a prompt counts as the latest one for [`said_in_latest_prompt`].
331pub const LATEST_PROMPT_SECS: u64 = 3600;
332
333/// The longest prompt kept for [`said_in_latest_prompt`].
334const LATEST_PROMPT_CHARS: usize = 8000;
335
336fn latest_prompt_path() -> std::path::PathBuf {
337    runtime_dir().join("latest-prompt")
338}
339
340/// Keep the person's prompt, so an agent's `ljos remember` of their words
341/// can be told from the agent's own lesson. The file sits in the login's
342/// runtime directory, readable by its owner only, and each prompt
343/// replaces it. Off when [`capture_mode`] is off.
344pub fn note_latest_prompt(prompt: &str) {
345    if capture_mode() == CaptureMode::Off || pasted_or_quoted(prompt) {
346        return;
347    }
348    let path = latest_prompt_path();
349    if let Some(dir) = path.parent() {
350        let _ = std::fs::create_dir_all(dir);
351    }
352    let text: String = prompt.chars().take(LATEST_PROMPT_CHARS).collect();
353    use std::io::Write as _;
354    use std::os::unix::fs::OpenOptionsExt as _;
355    if let Ok(mut f) = std::fs::OpenOptions::new()
356        .write(true)
357        .create(true)
358        .truncate(true)
359        .mode(0o600)
360        .open(&path)
361    {
362        let _ = f.write_all(text.as_bytes());
363    }
364}
365
366/// Lowercase words and nothing else, for comparing a sentence with a
367/// prompt whatever their spacing and punctuation.
368fn words_only(text: &str) -> String {
369    text.split(|c: char| !c.is_alphanumeric() && c != '\'')
370        .filter(|w| !w.is_empty())
371        .map(str::to_lowercase)
372        .collect::<Vec<_>>()
373        .join(" ")
374}
375
376/// Whether `text` is the person's words: it appears in the prompt they
377/// sent last, within [`LATEST_PROMPT_SECS`]. Four words or more, so "use
378/// bun" alone is not taken as a quote.
379#[must_use]
380pub fn said_in_latest_prompt(text: &str) -> bool {
381    let want = words_only(text);
382    if want.split(' ').count() < 4 {
383        return false;
384    }
385    let path = latest_prompt_path();
386    let fresh = std::fs::metadata(&path)
387        .and_then(|m| m.modified())
388        .ok()
389        .and_then(|t| t.elapsed().ok())
390        .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
391    if !fresh {
392        return false;
393    }
394    let Ok(prompt) = std::fs::read_to_string(&path) else {
395        return false;
396    };
397    format!(" {} ", words_only(&prompt)).contains(&format!(" {want} "))
398}
399
400/// The session key that keeps one stated choice from being saved twice.
401fn capture_key(text: &str) -> String {
402    use std::hash::{Hash, Hasher};
403    let mut h = std::collections::hash_map::DefaultHasher::new();
404    words_only(text).hash(&mut h);
405    format!("capture:{:016x}", h.finish())
406}
407
408/// How long the prompt hook waits for the pack to take a stated choice.
409/// Runners cut a hook off at 10 to 20 s, and the search behind it needs
410/// its own share.
411pub const CAPTURE_BUDGET_MS: u64 = 3000;
412
413/// Write a stated choice as the person's own claim: origin user, standing,
414/// tagged `via:prompt`. The atom the pack answered with.
415fn save(stated: &Stated) -> Option<Value> {
416    with_pack_timeout(CAPTURE_BUDGET_MS, || {
417        let client = pack().ok()?;
418        let workspace = client.workspace();
419        let mut atom = atom_body(stated.kind, &stated.text, &workspace);
420        add_entities(&mut atom, ["via:prompt".to_string()]);
421        stamp_horizon(&mut atom, stated.kind, &stated.text, Some(false));
422        admit::stamp_origin(&mut atom, admit::ORIGIN_USER);
423        let posted = admit::post_kept(&client, &atom).ok()?;
424        admit::satisfy_text(&stated.text);
425        Some(posted)
426    })
427}
428
429/// On a prompt that states a standing choice, save it (or nudge, per
430/// [`capture_mode`]) and return the one line the note carries and the
431/// keys to mark seen when the note is delivered. The saved atom's id is
432/// marked seen at once, so the same prompt's note does not repeat it.
433#[must_use]
434pub fn capture_stated(call: &HookCall) -> Option<(String, Vec<String>)> {
435    if call.event != "UserPromptSubmit" {
436        return None;
437    }
438    let mode = capture_mode();
439    if mode == CaptureMode::Off {
440        return None;
441    }
442    let stated = stated_preference(&call.cue)?;
443    let key = capture_key(&stated.text);
444    let session = call.session.as_deref();
445    if seen_ids(session).contains(&key) {
446        return None;
447    }
448    let verb = if stated.kind == "lesson" {
449        "remember"
450    } else {
451        "prefer"
452    };
453    if mode == CaptureMode::Nudge {
454        return Some((
455            format!(
456                "This prompt states a standing choice. Write it in the person's words with `ljos {verb} \"{}\"` so the hook raises it next time.",
457                stated.text.replace('"', "'")
458            ),
459            vec![key],
460        ));
461    }
462    let Some(posted) = save(&stated) else {
463        return Some((
464            format!(
465                "This prompt states a standing choice and the pack did not take it. Write it with `ljos {verb} \"{}\"`.",
466                stated.text.replace('"', "'")
467            ),
468            vec![key],
469        ));
470    };
471    let id = posted["id"].as_str().unwrap_or("").to_string();
472    let mut now = vec![key.clone()];
473    if !id.is_empty() {
474        now.push(id.clone());
475    }
476    // The correction hook would file the same words as a proposal and say
477    // so; the person's words are already saved.
478    if let Some(cue) = super::correction_cue(&call.cue) {
479        now.push(format!("correction:{cue}"));
480        now.push(format!("correction-stored:{cue}"));
481    }
482    mark_seen(session, &now);
483    let what = if stated.kind == "lesson" {
484        "lesson"
485    } else {
486        "preference"
487    };
488    let undo = if id.is_empty() {
489        String::new()
490    } else {
491        format!(" `ljos forget {id}` takes it back.")
492    };
493    Some((
494        format!(
495            "Saved the person's {what} in their words, standing from now: \"{}\".{undo}",
496            stated.text
497        ),
498        vec![key],
499    ))
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    const SAVES: &[&str] = &[
507        "From now on always use bun, never yarn, in this repo",
508        "Always run cargo fmt before committing.",
509        "Never push directly to main.",
510        "Going forward, write commit messages in the imperative mood.",
511        "Please always use pnpm for installs.",
512        "Remember that the staging database is read-only on Fridays.",
513        "Use bun, never yarn.",
514        "From now on, run the linter before every push.",
515        "Don't ever commit the .env file.",
516        "Never use force push on shared branches.",
517        "ok, from now on use tabs in Go files",
518        "We always squash merge pull requests here.",
519        "Always ask before running a database migration.",
520        "In future, put new tests next to the code they cover.",
521        "Stop using unwrap in library code.",
522        "I prefer small commits with one change each.",
523        "Henceforth all scripts use bash strict mode.",
524        "Remember to bump the version in Cargo.toml before tagging.",
525        "Never deploy on a Friday afternoon.",
526        "Keep it short, and from now on skip the summary at the end.",
527        "Always write `set -euo pipefail` at the top of shell scripts.",
528        "Make sure you always run the tests first.",
529        "We never edit an applied migration.",
530        "Thanks. From now on use uv instead of pip.",
531        "Use rg instead of grep going forward.",
532        "Always prefer rtrash over rm.",
533        "Never commit secrets to the repo.",
534        "Please never run npm install in this project.",
535        "From here on, write docs one sentence per line.",
536        "Also, always pin action versions by SHA.",
537    ];
538
539    const SAVES_NOTHING: &[&str] = &[
540        "Why do you always use yarn?",
541        "Should we always run the full suite?",
542        "Does this ever fail on CI?",
543        "Fix the failing test in parser.rs",
544        "You never listen to me",
545        "It always fails on the second run",
546        "Never mind, that was my mistake.",
547        "```\nalways_on = true\nnever_retry = false\n```",
548        "> Never push directly to main\nwhat does this rule mean",
549        "The doc says \"always use bun, never yarn, for this repo\" but is that right",
550        "error: always-run hook failed\nwarning: never-again flag set\nerror: exit 1",
551        "This could break in the future.",
552        "Run the tests and tell me what fails",
553        "I always forget how this works, can you explain it",
554        "Never seen this error before, what is it",
555        "Always the same problem with this build",
556        "What's the best way to always keep the cache warm?",
557        "Add a flag `--always-retry` to the CLI",
558        "Rename never_called to unused in lib.rs",
559        "Can you remember that function we wrote yesterday?",
560        "It never worked on macOS anyway",
561        "Remember when the build broke last week",
562        "Is it true that git never forgets a commit",
563        "Please look at the failing job",
564        "Traceback (most recent call last):\n  File \"a.py\", line 3\nerror: always broken\nwarning: never set",
565        "Always?",
566        "never",
567        "The upstream README says to always use yarn",
568        "Explain why we never got the webhook",
569        "Going forward is the only option for the migration",
570    ];
571
572    /// A Codex subagent's brief, Codex's review thread and an unattended
573    /// run are not the person; the root thread's prompt and Claude Code's
574    /// main thread are.
575    #[test]
576    fn a_subagent_brief_or_unattended_run_is_not_the_person() {
577        let _g = crate::tests::env_guard();
578        unsafe { std::env::remove_var("LJOS_UNATTENDED") };
579        let root = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
580        let other = "019a1b2c-3d4e-7f80-9a1b-ffffffffffff";
581        let codex = |extra: &str, thread: &str| {
582            format!(
583                r#"{{"hook_event_name":"UserPromptSubmit","session_id":"{root}","turn_id":"t-1","prompt":"Always run the tests with --release","transcript_path":"/home/u/.codex/sessions/2026/10/11/rollout-2026-10-11T09-30-00-{thread}.jsonl"{extra}}}"#
584            )
585        };
586        assert_eq!(not_the_person(&codex("", root)), None);
587        assert_eq!(
588            not_the_person(&codex(
589                r#","agent_id":"019a-sub","agent_type":"worker""#,
590                root
591            )),
592            Some("a subagent's brief")
593        );
594        assert_eq!(
595            not_the_person(&codex("", other)),
596            Some("another thread's prompt")
597        );
598        let claude_main = r#"{"hook_event_name":"UserPromptSubmit","session_id":"abc","prompt":"From now on use uv","transcript_path":"/home/u/.claude/projects/x/abc.jsonl","agent_type":"reviewer"}"#;
599        assert_eq!(
600            not_the_person(claude_main),
601            None,
602            "--agent alone is the main thread"
603        );
604        let claude_sub =
605            claude_main.replace(r#""agent_type""#, r#""agent_id":"sub-1","agent_type""#);
606        assert_eq!(not_the_person(&claude_sub), Some("a subagent's brief"));
607        unsafe { std::env::set_var("LJOS_UNATTENDED", "1") };
608        assert_eq!(not_the_person(claude_main), Some("LJOS_UNATTENDED is set"));
609        unsafe { std::env::set_var("LJOS_UNATTENDED", "0") };
610        assert_eq!(not_the_person(claude_main), None);
611        unsafe { std::env::remove_var("LJOS_UNATTENDED") };
612    }
613
614    #[test]
615    fn a_rollout_name_gives_its_thread() {
616        let id = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
617        assert_eq!(
618            rollout_thread(&format!("/x/rollout-2026-10-11T09-30-00-{id}.jsonl")).as_deref(),
619            Some(id)
620        );
621        assert_eq!(
622            rollout_thread(&format!("rollout-2026-10-11T09-30-00-{id}_0042.jsonl")).as_deref(),
623            Some(id)
624        );
625        assert_eq!(rollout_thread("/x/abc.jsonl"), None);
626        assert_eq!(
627            rollout_thread("rollout-2026-10-11T09-30-00-not-a-uuid.jsonl"),
628            None
629        );
630    }
631
632    #[test]
633    fn thirty_stated_choices_are_read() {
634        assert_eq!(SAVES.len(), 30);
635        let missed: Vec<&&str> = SAVES
636            .iter()
637            .filter(|p| stated_preference(p).is_none())
638            .collect();
639        assert!(
640            missed.is_empty(),
641            "not read as a standing choice: {missed:#?}"
642        );
643    }
644
645    #[test]
646    fn thirty_other_prompts_save_nothing() {
647        assert_eq!(SAVES_NOTHING.len(), 30);
648        let saved: Vec<(&&str, Stated)> = SAVES_NOTHING
649            .iter()
650            .filter_map(|p| stated_preference(p).map(|s| (p, s)))
651            .collect();
652        assert!(saved.is_empty(), "read as a standing choice: {saved:#?}");
653    }
654
655    #[test]
656    fn the_saved_words_are_the_persons_sentence() {
657        let s = stated_preference("From now on always use bun, never yarn, in this repo").unwrap();
658        assert_eq!(s.kind, "preference");
659        assert_eq!(
660            s.text,
661            "From now on always use bun, never yarn, in this repo"
662        );
663        let s = stated_preference("ok, from now on use tabs in Go files").unwrap();
664        assert_eq!(s.text, "From now on use tabs in Go files");
665        let s = stated_preference("Thanks. From now on use uv instead of pip.").unwrap();
666        assert_eq!(s.text, "From now on use uv instead of pip.");
667        let s = stated_preference("Remember that the staging database is read-only on Fridays.")
668            .unwrap();
669        assert_eq!(s.kind, "lesson");
670        assert_eq!(s.text, "The staging database is read-only on Fridays.");
671    }
672
673    #[test]
674    fn the_mode_reads_ljos_capture() {
675        let _g = crate::tests::env_guard();
676        let prev = std::env::var_os("LJOS_CAPTURE");
677        for (v, want) in [
678            ("", CaptureMode::Save),
679            ("save", CaptureMode::Save),
680            ("nudge", CaptureMode::Nudge),
681            ("off", CaptureMode::Off),
682            ("bogus", CaptureMode::Save),
683        ] {
684            unsafe { std::env::set_var("LJOS_CAPTURE", v) };
685            assert_eq!(capture_mode(), want, "{v}");
686        }
687        match prev {
688            Some(v) => unsafe { std::env::set_var("LJOS_CAPTURE", v) },
689            None => unsafe { std::env::remove_var("LJOS_CAPTURE") },
690        }
691    }
692
693    #[test]
694    fn words_compare_whatever_the_punctuation() {
695        assert_eq!(words_only("Use bun,  never   yarn!"), "use bun never yarn");
696    }
697}