Skip to main content

ljos_cli/
command_prefs.rs

1//! Preferences at the moment the agent acts.
2//!
3//! "Use pnpm instead of npm" came back on a prompt that shared its words,
4//! and `npm install lodash` still ran with nothing said. Two things close
5//! that: a preference that forbids a command it can name proposes an ask
6//! rule over that command ([`rule_candidates`]), and the tool-call hook
7//! hands over the preferences that name the command about to run
8//! ([`naming_command`]). Both read words only, so neither costs the hook
9//! more than the pack listing it already makes for the rules.
10
11use super::Rule;
12
13/// Openings after which a preference names what it forbids. "Ask before"
14/// asks, the rest forbid; the rule is an ask either way, so the person
15/// who is stopped decides.
16const CUES: &[&str] = &[
17    "instead of",
18    "rather than",
19    "ask before",
20    "check before",
21    "never",
22    "don't",
23    "do not",
24    "avoid",
25    "stop using",
26    "not ",
27];
28
29/// A word the forbidden part names, and the ask rule it gives.
30const TABLE: &[(&[&str], &str)] = &[
31    (&["npm"], r"re:npm\s+(install|i|add|ci)\b"),
32    (&["yarn"], r"re:yarn(\s+(add|install)\b|\s*$)"),
33    (&["pip", "pip3"], r"re:(python3?\s+-m\s+)?pip3?\s+install\b"),
34    (
35        &["force-push", "force"],
36        // `--force` at a word end, or a `--force-with-lease` push (any
37        // form) that names main, master or develop on either side of it.
38        // A lease push onto another branch and `--force-if-includes`
39        // alone stay ordinary pushes; the guard reads a bare lease push.
40        r"re:git\s+push\b.*(--force( |$)|-f( |$)|--force-with-lease\S*.*[\s:=](refs/heads/)?(main|master|develop)(\s|:|$)|[\s:=](refs/heads/)?(main|master|develop)(\s|:).*--force-with-lease)",
41    ),
42    (&["push"], r"re:git\s+push\b.*\b(main|master)\b"),
43    (&[".env"], r"re:.*>>?\s*\S*\.env\b"),
44    (
45        &["migration", "migrations", "migrate"],
46        r"re:(psql\b.*-f\s*\S*migrat|rails\s+db:migrate|bin/rails\s+db:migrate|alembic\s+upgrade|flyway\s+migrate|prisma\s+migrate\s+deploy|sqlx\s+migrate\s+run|diesel\s+migration\s+run|knex\s+migrate|goose\b.*\bup\b)",
47    ),
48    (
49        &["terraform", "tofu"],
50        r"re:(terraform|tofu)\b.*\b(destroy|apply\b.*-destroy)\b",
51    ),
52    (&["kubectl"], r"re:kubectl\b.*\bdelete\b"),
53    (
54        &["docker"],
55        r"re:docker\s+(system\s+prune|volume\s+(rm|prune)|rm|rmi)\b",
56    ),
57];
58
59/// Words that tie a migration preference to a shared database.
60const REMOTE_WORDS: &[&str] = &["staging", "stage", "prod", "production", "live"];
61
62/// A migration run that names a shared database, in either order.
63const MIGRATION_REMOTE: &str = r"re:.*\b(staging|stage|prod|production|live)\b.*\b(migrat\w*|upgrade)\b|.*\b(migrat\w*|upgrade)\b.*\b(staging|stage|prod|production|live)\b";
64
65/// Words that make a migration preference about running one.
66const RUN_WORDS: &[&str] = &[
67    "run",
68    "running",
69    "apply",
70    "applying",
71    "against",
72    "migrate",
73    "migrating",
74];
75
76/// The forbidden part of a preference: what follows its first cue, up to
77/// the end of that clause. `None` when it has no cue.
78fn forbidden_part(text: &str) -> Option<String> {
79    let lower = format!(" {} ", text.to_lowercase());
80    let (at, cue) = CUES
81        .iter()
82        .filter_map(|c| lower.find(&format!(" {c}")).map(|i| (i, *c)))
83        .min_by_key(|(i, _)| *i)?;
84    let rest = &lower[at + 1 + cue.len()..];
85    let end = rest.find([';', '.', ':']).map_or(rest.len(), |i| {
86        // A dot inside a word, as in `.env`, does not end the clause.
87        let next = rest[i + 1..].chars().next();
88        if rest.as_bytes()[i] == b'.' && next.is_some_and(char::is_alphanumeric) {
89            rest[i + 1..]
90                .find([';', ':'])
91                .map_or(rest.len(), |j| i + 1 + j)
92        } else {
93            i
94        }
95    });
96    Some(rest[..end].to_string())
97}
98
99/// Words of a clause, kept with their dots so `.env` stays one word.
100fn words(clause: &str) -> Vec<String> {
101    clause
102        .split(|c: char| c.is_whitespace() || matches!(c, ',' | '(' | ')' | '"' | '`' | '\''))
103        .map(|w| w.trim_end_matches(['.', '!', '?']).to_lowercase())
104        .filter(|w| !w.is_empty())
105        .collect()
106}
107
108/// The ask rules a preference proposes: one per command its forbidden
109/// part names in [`TABLE`], each with the preference as its reason.
110/// "Use pnpm instead of npm" gives one over `npm install`; "Use tabs in
111/// Go files" names nothing it forbids and gives none.
112#[must_use]
113pub fn rule_candidates(text: &str) -> Vec<Rule> {
114    let Some(part) = forbidden_part(text) else {
115        return Vec::new();
116    };
117    let named = words(&part);
118    let mut rules: Vec<Rule> = Vec::new();
119    for (keys, pattern) in TABLE {
120        if keys.iter().any(|k| named.iter().any(|w| w == k))
121            && !rules.iter().any(|r| r.pattern == *pattern)
122        {
123            // A migration rule asks before one runs, so the preference
124            // must be about running one: "never edit a migration that has
125            // been applied" is about the file.
126            if keys.contains(&"migration") && !named.iter().any(|w| RUN_WORDS.contains(&w.as_str()))
127            {
128                continue;
129            }
130            // "force push" is one rule, not a force rule and a main one.
131            if *pattern == TABLE[4].1 && rules.iter().any(|r| r.pattern == TABLE[3].1) {
132                continue;
133            }
134            // "Against staging or production" asks there and not on a
135            // migration run against a dev database.
136            let pattern = if keys.contains(&"migration")
137                && named.iter().any(|w| REMOTE_WORDS.contains(&w.as_str()))
138            {
139                MIGRATION_REMOTE
140            } else {
141                *pattern
142            };
143            if rules.iter().any(|r| r.pattern == pattern) {
144                continue;
145            }
146            rules.push(Rule {
147                pattern: pattern.to_string(),
148                verdict: "ask".into(),
149                reason: text.trim().to_string(),
150            });
151        }
152    }
153    // An auto-written rule is the command the preference cites, not a
154    // blanket over every branch push.
155    rules.retain(|rule| !crate::blankets_ordinary_pushes(&rule.pattern));
156    if crate::under_a_runner() {
157        rules.retain(|rule| !crate::broader_than_cited(&rule.pattern, &rule.reason));
158    }
159    rules
160}
161
162/// Words too common to tie a preference to a command.
163const STOP: &[&str] = &[
164    "a", "an", "and", "any", "are", "as", "at", "be", "before", "by", "every", "for", "from", "in",
165    "into", "is", "it", "its", "of", "on", "or", "our", "the", "them", "this", "to", "use",
166    "using", "run", "running", "with", "when", "we", "you", "your", "always", "never", "instead",
167    "prefer", "keep", "make", "do", "not", "plain", "file", "files", "repo", "here", "all", "each",
168    "new", "via", "than", "rather", "only", "first", "then",
169];
170
171/// Stem a word just enough that "migrations" meets "migration".
172fn stem(word: &str) -> &str {
173    word.strip_suffix('s')
174        .filter(|s| s.len() >= 3)
175        .unwrap_or(word)
176}
177
178/// What a command line says: per segment, the program, its subcommand,
179/// the files it writes to, and every word piece of its arguments.
180struct CommandWords {
181    heads: Vec<String>,
182    pieces: Vec<String>,
183}
184
185fn command_words(line: &str) -> CommandWords {
186    let mut heads = Vec::new();
187    let mut pieces = Vec::new();
188    for segment in line.split(['|', ';', '&', '\n']) {
189        let mut toks = segment.split_whitespace().peekable();
190        // `FOO=1 sudo env npm i` runs npm.
191        while let Some(t) = toks.peek() {
192            let assignment = t.split_once('=').is_some_and(|(k, _)| {
193                !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
194            });
195            if assignment || matches!(*t, "sudo" | "env" | "command" | "exec" | "time" | "nice") {
196                toks.next();
197            } else {
198                break;
199            }
200        }
201        let Some(program) = toks.next() else { continue };
202        let program = program.rsplit('/').next().unwrap_or(program).to_lowercase();
203        heads.push(program.clone());
204        let rest: Vec<&str> = toks.collect();
205        if let Some(sub) = rest.iter().find(|w| !w.starts_with('-')) {
206            if matches!(
207                program.as_str(),
208                "git" | "cargo" | "go" | "docker" | "kubectl" | "npm"
209            ) {
210                heads.push(sub.to_lowercase());
211            }
212        }
213        for (i, w) in rest.iter().enumerate() {
214            let target = if let Some(t) = w.strip_prefix(">>").or_else(|| w.strip_prefix('>')) {
215                if t.is_empty() {
216                    rest.get(i + 1).copied()
217                } else {
218                    Some(t)
219                }
220            } else {
221                None
222            };
223            if let Some(t) = target {
224                let base = t.rsplit('/').next().unwrap_or(t).to_lowercase();
225                heads.push(base);
226            }
227            for p in w.split(|c: char| !c.is_alphanumeric()) {
228                if p.len() >= 3 && !p.chars().all(|c| c.is_ascii_digit()) {
229                    pieces.push(p.to_lowercase());
230                }
231            }
232        }
233    }
234    CommandWords { heads, pieces }
235}
236
237/// Whether a preference bears on a command line. It does when the words
238/// it forbids (or, with no cue, all its words) name the command's
239/// program, its subcommand or a file it writes to, or share two content
240/// words with the command's arguments. "Use pnpm instead of npm" bears on
241/// `npm install lodash` and not on `pnpm add lodash`; "Ask before running
242/// any database migration against staging" bears on
243/// `psql -h staging-db -f migrations/007.sql`.
244#[must_use]
245pub fn bears_on(preference: &str, line: &str) -> bool {
246    let clause = forbidden_part(preference).unwrap_or_else(|| preference.to_lowercase());
247    let named: Vec<String> = words(&clause)
248        .into_iter()
249        .filter(|w| !STOP.contains(&w.as_str()) && w.len() >= 2)
250        .collect();
251    if named.is_empty() {
252        return false;
253    }
254    let cmd = command_words(line);
255    if cmd.heads.iter().any(|h| named.iter().any(|w| w == h)) {
256        return true;
257    }
258    let mut shared: Vec<&str> = named
259        .iter()
260        .map(|w| stem(w))
261        .filter(|w| cmd.pieces.iter().any(|p| stem(p) == *w))
262        .collect();
263    shared.sort_unstable();
264    shared.dedup();
265    shared.len() >= 2
266}
267
268/// The preferences, as (id, text), that bear on `line`, at most `cap`.
269#[must_use]
270pub fn naming_command<'a>(
271    preferences: &'a [(String, String)],
272    line: &str,
273    cap: usize,
274) -> Vec<&'a (String, String)> {
275    if line.trim().is_empty() {
276        return Vec::new();
277    }
278    preferences
279        .iter()
280        .filter(|(_, text)| bears_on(text, line))
281        .take(cap)
282        .collect()
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::rule_matches;
289
290    const PREFS: &[&str] = &[
291        "Use pnpm instead of npm for every JavaScript package install in this repo",
292        "Run cargo clippy with -D warnings before every commit",
293        "Never push directly to main; open a pull request from a feature branch",
294        "Write commit subjects in the imperative mood with a conventional prefix such as fix: or feat:",
295        "Use uv to run Python tools instead of pip or plain python",
296        "Keep secrets in 1Password and never write them into .env files",
297        "Format SQL migrations with sqlfluff and never edit a migration that has been applied",
298        "Use tabs for indentation in Go files and gofmt them",
299        "Ask before running any database migration against staging or production",
300        "Prefer table-driven tests in Go and parametrize in pytest",
301    ];
302
303    /// Each preference's candidates, as the patterns they match on.
304    fn asks(text: &str, line: &str) -> bool {
305        rule_candidates(text)
306            .iter()
307            .any(|r| rule_matches(&r.pattern, line))
308    }
309
310    #[test]
311    fn a_forbidding_preference_proposes_an_ask_over_its_command() {
312        assert!(asks(PREFS[0], "npm install lodash"));
313        assert!(asks(PREFS[0], "npm i -D vitest"));
314        assert!(!asks(PREFS[0], "pnpm add lodash"));
315        assert!(!asks(PREFS[0], "npm test"));
316        assert!(asks(PREFS[2], "git push origin main"));
317        assert!(!asks(PREFS[2], "git push origin feature/x"));
318        assert!(asks(PREFS[4], "pip install black"));
319        assert!(asks(PREFS[4], "python3 -m pip install -r requirements.txt"));
320        assert!(!asks(PREFS[4], "uv pip install black"));
321        assert!(asks(PREFS[5], "echo STRIPE_KEY=sk_live_x >> .env"));
322        assert!(!asks(PREFS[5], "cat .env.example"));
323        assert!(asks(PREFS[8], "psql -h staging-db -f migrations/007.sql"));
324        assert!(asks(PREFS[8], "RAILS_ENV=production bin/rails db:migrate"));
325        assert!(asks(PREFS[8], "alembic -x env=prod upgrade head"));
326        assert!(!asks(PREFS[8], "alembic upgrade head"));
327        assert!(!asks(PREFS[8], "psql -f migrations/001_init.sql app_dev"));
328        assert!(asks(
329            "Ask before running a database migration",
330            "bin/rails db:migrate"
331        ));
332        assert!(!asks(PREFS[8], "psql -h staging-db -c 'select 1'"));
333        assert!(asks(
334            "Never force push to shared branches",
335            "git push --force origin dev"
336        ));
337        assert!(!asks(
338            "Never force push to shared branches",
339            "git push origin main"
340        ));
341        assert!(asks("Never force push", "git push --force origin dev"));
342        assert!(asks("Never force push", "git push -f origin dev"));
343        assert!(asks(
344            "Never force push",
345            "git push --force-with-lease origin HEAD:main"
346        ));
347        assert!(asks(
348            "Never force push",
349            "git push --force-with-lease=main:abc123 origin main"
350        ));
351        assert!(!asks("Never force push", "git push -u origin feature"));
352        assert!(asks(
353            "Never force push",
354            "git push --force-with-lease origin develop"
355        ));
356        assert!(asks(
357            "Never force push",
358            "git push origin main --force-with-lease"
359        ));
360        assert!(!asks(
361            "Never force push",
362            "git push --force-with-lease origin feature"
363        ));
364        assert!(!asks(
365            "Never force push",
366            "git push --force-with-lease --force-if-includes origin feature/main-menu"
367        ));
368        assert!(!asks(
369            "Never force push",
370            "git push --force-with-lease origin maintenance"
371        ));
372        assert!(!asks(
373            "Never force push",
374            "git push --force-if-includes origin main"
375        ));
376        assert!(asks(
377            "Never run terraform destroy",
378            "terraform -chdir=infra destroy"
379        ));
380        assert!(asks("Don't use yarn here", "yarn add lodash"));
381        assert!(asks("Don't use yarn here", "yarn"));
382        // Nothing forbidden, or nothing the table knows: no rule.
383        for text in [
384            PREFS[1],
385            PREFS[3],
386            PREFS[6],
387            PREFS[7],
388            PREFS[9],
389            "Never use unwrap in library code",
390        ] {
391            assert!(rule_candidates(text).is_empty(), "{text}");
392        }
393        let n = PREFS
394            .iter()
395            .filter(|p| !rule_candidates(p).is_empty())
396            .count();
397        assert_eq!(n, 5, "five of the ten preferences carry a rule");
398        assert!(
399            rule_candidates(PREFS[6]).is_empty(),
400            "editing a migration is not running one"
401        );
402        assert!(rule_candidates(PREFS[0])
403            .iter()
404            .all(|r| r.verdict == "ask" && r.reason == PREFS[0]));
405    }
406
407    #[test]
408    fn a_preference_bears_on_the_command_it_names() {
409        let yes = [
410            (0, "npm install lodash"),
411            (2, "git push origin main"),
412            (1, "git commit -m 'fix: x'"),
413            (3, "git commit -m 'fix: x'"),
414            (4, "pip install black"),
415            (5, "echo STRIPE_KEY=sk_live_x >> .env"),
416            (8, "psql -h staging-db -f migrations/007.sql"),
417            (9, "pytest -q tests/"),
418            (7, "go build ./..."),
419        ];
420        for (i, line) in yes {
421            assert!(bears_on(PREFS[i], line), "{} on {line}", PREFS[i]);
422        }
423        let no = [
424            (0, "pnpm add lodash"),
425            (0, "pip install black"),
426            (4, "uv run black ."),
427            (2, "git status"),
428            (5, "cat README.md"),
429            (8, "psql -h localhost -c 'select 1'"),
430            (6, "ls"),
431        ];
432        for (i, line) in no {
433            assert!(!bears_on(PREFS[i], line), "{} on {line}", PREFS[i]);
434        }
435        let prefs: Vec<(String, String)> = PREFS
436            .iter()
437            .enumerate()
438            .map(|(i, t)| (format!("id{i}"), (*t).to_string()))
439            .collect();
440        let hit = naming_command(&prefs, "npm install lodash", 2);
441        assert_eq!(hit.len(), 1);
442        assert_eq!(hit[0].0, "id0");
443        assert!(naming_command(&prefs, "  ", 2).is_empty());
444    }
445}