use crate::shell::{Connector, Parsed, Simple, Word};
pub const NEAR: f64 = 0.34;
const MAX_TOKENS: usize = 48;
const VERB_PROGRAMS: &[&str] = &[
"amont",
"amont-agent",
"argocd",
"aws",
"az",
"brew",
"bun",
"cargo",
"deno",
"docker",
"flux",
"gcloud",
"gh",
"git",
"glab",
"go",
"helm",
"just",
"kubectl",
"launchctl",
"make",
"npm",
"pip",
"pip3",
"pnpm",
"podman",
"rustup",
"systemctl",
"terraform",
"tofu",
"uv",
"yarn",
];
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Shape {
tokens: Vec<String>,
}
impl Shape {
pub fn of(parsed: &Parsed) -> Shape {
let clauses: Vec<&Simple> = parsed
.clauses()
.iter()
.filter(|c| c.nested.is_none())
.collect();
let mut tokens = Vec::new();
for (i, c) in clauses.iter().enumerate() {
if i > 0 {
if let Some(conn) = clauses[i - 1].next {
tokens.push(connector(conn).to_string());
}
}
if c.opaque.is_some() {
tokens.push("<unread>".to_string());
continue;
}
stage(c, &mut tokens);
}
Shape { tokens }
}
pub fn of_command(src: &str) -> Shape {
Shape::of(&crate::shell::lex(src))
}
pub fn is_empty(&self) -> bool {
self.tokens.is_empty()
}
pub fn text(&self) -> String {
self.tokens.join(" ")
}
}
fn connector(c: Connector) -> &'static str {
match c {
Connector::Pipe => "|",
Connector::AndAnd => "&&",
Connector::OrOr => "||",
Connector::Semi => ";",
Connector::Amp => "&",
}
}
fn stage(c: &Simple, out: &mut Vec<String>) {
let Some(prog) = c.program() else {
out.push("<assign>".to_string());
return;
};
let name = basename(prog);
out.push(name.to_string());
let verbs = if VERB_PROGRAMS.contains(&name) { 2 } else { 0 };
let mut kept = 0usize;
for w in c.operands() {
if kept < verbs && is_bare_word(w) {
out.push(w.text.clone());
kept += 1;
} else {
out.push(mask(w).to_string());
}
}
let mut flags: Vec<String> = Vec::new();
for w in c.args() {
if !w.quoted && w.text == "--" {
break;
}
if w.quoted || w.expanded {
continue;
}
let t = w.text.as_str();
if t.len() < 2 || !t.starts_with('-') {
continue;
}
let name = match t.find('=') {
Some(k) => format!("{}=", &t[..k]),
None => t.to_string(),
};
if !flags.contains(&name) {
flags.push(name);
}
}
flags.sort();
out.extend(flags);
for (op, target) in &c.redirects {
out.push(op.clone());
if !target.raw.is_empty() {
out.push(mask(target).to_string());
}
}
if c.heredoc {
out.push("<<".to_string());
}
}
fn basename(prog: &str) -> &str {
prog.rsplit('/').next().unwrap_or(prog)
}
fn is_bare_word(w: &Word) -> bool {
!w.quoted
&& !w.expanded
&& !w.text.starts_with('-')
&& w.text.len() <= 32
&& w.text.chars().any(|c| c.is_ascii_alphabetic())
&& w.text
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
fn mask(w: &Word) -> &'static str {
if w.expanded {
return "<sub>";
}
if w.quoted {
return "<str>";
}
let t = w.text.as_str();
if t.is_empty() {
return "<str>";
}
if t.contains("://") {
return "<url>";
}
if t.chars().any(|c| matches!(c, '*' | '?' | '[')) {
return "<glob>";
}
if t.chars().any(|c| c.is_ascii_digit()) && !t.chars().any(|c| c.is_ascii_alphabetic()) {
return "<n>";
}
if t.contains('/') || t.starts_with('.') || t.starts_with('~') {
return "<path>";
}
if t.rsplit_once('.')
.is_some_and(|(stem, ext)| !stem.is_empty() && (1..=5).contains(&ext.len()))
{
return "<path>";
}
"<word>"
}
pub fn distance(a: &Shape, b: &Shape) -> f64 {
let x = &a.tokens[..a.tokens.len().min(MAX_TOKENS)];
let y = &b.tokens[..b.tokens.len().min(MAX_TOKENS)];
let longest = x.len().max(y.len());
if longest == 0 {
return 0.0;
}
levenshtein(x, y) as f64 / longest as f64
}
fn levenshtein(a: &[String], b: &[String]) -> usize {
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ai) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, bj) in b.iter().enumerate() {
let cost = usize::from(ai != bj);
cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
pub fn pick_novel(candidates: &[Shape], seeds: &[Shape], n: usize) -> Vec<usize> {
let mut nearest: Vec<f64> = candidates
.iter()
.map(|c| {
seeds
.iter()
.map(|s| distance(c, s))
.fold(f64::INFINITY, f64::min)
})
.collect();
let mut chosen = Vec::new();
while chosen.len() < n.min(candidates.len()) {
let mut best = usize::MAX;
let mut best_d = f64::NEG_INFINITY;
for (i, d) in nearest.iter().enumerate() {
if chosen.contains(&i) {
continue;
}
if *d > best_d {
best_d = *d;
best = i;
}
}
if best == usize::MAX {
break;
}
chosen.push(best);
for (i, d) in nearest.iter_mut().enumerate() {
*d = d.min(distance(&candidates[i], &candidates[best]));
}
}
chosen
}
#[cfg(test)]
mod tests {
use super::*;
fn shape(src: &str) -> String {
Shape::of_command(src).text()
}
#[test]
fn literals_are_masked_and_the_verb_survives() {
assert_eq!(
shape("git push origin feat/mine 2>&1 | tail -5"),
"git push origin <path> 2>&1 | tail -5"
);
assert_eq!(shape("git stash pop"), "git stash pop");
assert_eq!(shape("git stash list"), "git stash list");
}
#[test]
fn two_spellings_of_one_habit_are_one_shape() {
assert_eq!(
shape("grep -rn needle src/foo.rs"),
shape("grep -rn other src/bar.rs")
);
assert_eq!(shape("sed -n '1,80p' a.rs"), shape("sed -n '4,9p' b.rs"));
assert_eq!(
shape("git worktree add ../a -b feat/x"),
shape("git worktree add ../b -b fix/y")
);
}
#[test]
fn a_verb_survives_and_a_search_pattern_does_not() {
assert_ne!(shape("git stash pop"), shape("git stash list"));
assert_eq!(shape("grep alpha"), shape("grep beta"));
}
#[test]
fn flags_sort_and_operands_do_not() {
assert_eq!(shape("git commit -a -m 'x'"), shape("git commit -m 'x' -a"));
assert_ne!(shape("cp notes.md backup"), shape("cp backup notes.md"));
}
#[test]
fn a_flag_value_is_dropped_and_the_flag_is_kept() {
assert_eq!(
shape("rg --include=*.ts pat"),
shape("rg --include=*.rs pat")
);
assert!(shape("rg --include=*.ts pat").contains("--include="));
}
#[test]
fn a_flag_inside_a_message_is_not_part_of_the_shape() {
assert_eq!(
shape("git commit -m 'use --force here'"),
shape("git commit -m 'nothing special'")
);
assert!(!shape("git commit -m 'use --force here'").contains("--force"));
}
#[test]
fn a_pipeline_keeps_its_stages_and_its_connectors() {
assert_eq!(
shape("git status -s && git push | tail -2"),
"git status -s && git push | tail -2"
);
}
#[test]
fn the_path_a_program_was_found_at_is_not_its_identity() {
assert_eq!(shape("/usr/bin/git status"), shape("git status"));
}
#[test]
fn an_unreadable_pipeline_is_named_rather_than_dropped() {
let s = shape("git status -s | xargs git add && git push");
assert!(s.contains("<unread>"), "{s}");
assert_ne!(s, shape("git status -s && git push"));
}
#[test]
fn distance_is_zero_for_one_shape_and_one_for_two_programs() {
let a = Shape::of_command("git push origin main");
let b = Shape::of_command("git push origin other");
assert_eq!(distance(&a, &a), 0.0);
assert!(distance(&a, &b) <= NEAR, "{}", distance(&a, &b));
let c = Shape::of_command("kubectl apply -f x.yaml");
assert!(distance(&a, &c) > NEAR);
}
#[test]
fn push_and_pull_are_not_near_each_other() {
let push = Shape::of_command("git push");
let pull = Shape::of_command("git pull");
assert!(distance(&push, &pull) > NEAR, "{}", distance(&push, &pull));
}
#[test]
fn novelty_prefers_the_unlike_and_then_the_next_unlike() {
let candidates: Vec<Shape> = [
"git push origin main | tail -5",
"git push origin other | tail -5",
"kubectl apply -f a.yaml | tail -3",
"npm publish | tail -1",
]
.iter()
.map(|c| Shape::of_command(c))
.collect();
let picked = pick_novel(&candidates, &[], 2);
assert_eq!(picked.len(), 2);
assert!(!(picked.contains(&0) && picked.contains(&1)), "{picked:?}");
}
#[test]
fn a_seed_pushes_its_own_neighbourhood_down_the_list() {
let candidates: Vec<Shape> = ["git push origin main | tail -5", "npm publish | tail -1"]
.iter()
.map(|c| Shape::of_command(c))
.collect();
let seeds = vec![Shape::of_command("git push origin feat/x | tail -9")];
assert_eq!(pick_novel(&candidates, &seeds, 1), vec![1]);
}
#[test]
fn the_pick_is_reproducible() {
let candidates: Vec<Shape> = ["a b", "c d", "e f", "g h"]
.iter()
.map(|c| Shape::of_command(c))
.collect();
let once = pick_novel(&candidates, &[], 3);
assert_eq!(once, pick_novel(&candidates, &[], 3));
}
}