use std::ops::Range;
use crate::shell::Parsed;
pub mod bare_stash_pop;
pub mod gh_pr_merge_auto;
pub mod git_add_broad;
pub mod no_verify;
pub mod pipe_to_tail;
pub mod push_preflight;
pub mod stale_base;
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Stance {
Observe,
Advise,
Deny,
}
impl Stance {
pub fn as_str(self) -> &'static str {
match self {
Stance::Observe => "observe",
Stance::Advise => "advise",
Stance::Deny => "deny",
}
}
#[allow(dead_code)]
pub fn parse(s: &str) -> Option<Stance> {
match s {
"observe" => Some(Stance::Observe),
"advise" => Some(Stance::Advise),
"deny" => Some(Stance::Deny),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Finding {
pub reason: String,
pub remedy: String,
pub span: Range<usize>,
}
#[allow(dead_code)]
pub enum Confirmed {
Yes,
No(&'static str),
}
#[derive(Debug, Clone, Copy)]
pub struct Evidence {
pub per_1000: f32,
pub measured: &'static str,
pub trend: Trend,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trend {
Flat(u8),
Improving,
Rare,
}
pub struct Rule {
pub id: &'static str,
pub default_stance: Stance,
pub evidence: Evidence,
pub examine: fn(&Parsed) -> Option<Finding>,
#[allow(dead_code)]
pub confirm: Option<fn(&crate::rules::Context, &Finding) -> Confirmed>,
}
#[allow(dead_code)]
pub struct Context<'a> {
pub cwd: &'a std::path::Path,
pub parsed: &'a Parsed,
}
impl Context<'_> {
pub fn cwd_at(&self, at: usize) -> std::path::PathBuf {
let mut dir = self.cwd.to_path_buf();
for cmd in self.parsed.clauses() {
if cmd.at >= at {
break;
}
if cmd.program() != Some("cd") {
continue;
}
let target = cmd.words.iter().skip_while(|w| w.text != "cd").nth(1);
let Some(target) = target else {
if let Some(home) = std::env::var_os("HOME") {
dir = std::path::PathBuf::from(home);
}
continue;
};
if target.expanded || target.text.trim().is_empty() || target.text == "-" {
return self.cwd.to_path_buf();
}
let t = target.text.as_str();
dir = if let Some(rest) = t.strip_prefix("~/") {
match std::env::var_os("HOME") {
Some(home) => std::path::PathBuf::from(home).join(rest),
None => return self.cwd.to_path_buf(),
}
} else if t == "~" {
match std::env::var_os("HOME") {
Some(home) => std::path::PathBuf::from(home),
None => return self.cwd.to_path_buf(),
}
} else {
dir.join(t)
};
}
dir
}
}
pub const RULES: &[Rule] = &[
pipe_to_tail::RULE,
bare_stash_pop::RULE,
gh_pr_merge_auto::RULE,
no_verify::RULE,
git_add_broad::RULE,
stale_base::RULE,
push_preflight::RULE,
];
pub fn by_id(id: &str) -> Option<&'static Rule> {
RULES.iter().find(|r| r.id == id)
}
pub fn examine_all(parsed: &Parsed) -> Vec<(&'static Rule, Finding)> {
let mut out = Vec::new();
for rule in RULES {
let found =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (rule.examine)(parsed)));
match found {
Ok(Some(f)) => out.push((rule, f)),
Ok(None) => {}
Err(_) => eprintln!("amont-agent: rule `{}` panicked; ignoring it", rule.id),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn at_of(parsed: &Parsed, needle: &str) -> usize {
parsed
.clauses()
.iter()
.find(|c| c.words.iter().any(|w| w.text == needle))
.map(|c| c.at)
.expect("clause")
}
#[test]
fn a_leading_cd_moves_the_question() {
let parsed = lex("cd /tmp/elsewhere && git worktree add ../x -b feat/y");
let ctx = Context {
cwd: std::path::Path::new("/session"),
parsed: &parsed,
};
assert_eq!(
ctx.cwd_at(at_of(&parsed, "worktree")),
std::path::PathBuf::from("/tmp/elsewhere")
);
}
#[test]
fn a_relative_cd_resolves_against_the_session_and_the_last_wins() {
let parsed = lex("cd sub; cd deeper && git stash pop");
let ctx = Context {
cwd: std::path::Path::new("/session"),
parsed: &parsed,
};
assert_eq!(
ctx.cwd_at(at_of(&parsed, "stash")),
std::path::PathBuf::from("/session/sub/deeper")
);
}
#[test]
fn a_cd_after_the_clause_does_not_count() {
let parsed = lex("git stash pop && cd /tmp/after");
let ctx = Context {
cwd: std::path::Path::new("/session"),
parsed: &parsed,
};
assert_eq!(
ctx.cwd_at(at_of(&parsed, "stash")),
std::path::PathBuf::from("/session")
);
}
#[test]
fn an_unknowable_cd_falls_back_to_the_session() {
for command in ["cd $(mktemp -d) && git stash pop", "cd - && git stash pop"] {
let parsed = lex(command);
let ctx = Context {
cwd: std::path::Path::new("/session"),
parsed: &parsed,
};
assert_eq!(
ctx.cwd_at(at_of(&parsed, "stash")),
std::path::PathBuf::from("/session"),
"{command}"
);
}
}
#[test]
fn tilde_is_home() {
let parsed = lex("cd ~/work/repo && git stash pop");
let ctx = Context {
cwd: std::path::Path::new("/session"),
parsed: &parsed,
};
let home = std::path::PathBuf::from(std::env::var_os("HOME").expect("HOME"));
assert_eq!(ctx.cwd_at(at_of(&parsed, "stash")), home.join("work/repo"));
}
}