use std::ops::Range;
use crate::shell::Parsed;
pub mod amend_pushed;
pub mod bare_stash_pop;
pub mod branch_force_delete;
pub mod dump;
pub mod equals_separator;
pub mod file_reread;
pub mod foreground_poll;
pub mod forge_merge_by_hand;
pub mod forge_status_stale_row;
pub mod gh_pr_merge_auto;
pub mod git_add_broad;
pub mod glob_in_flag_value;
pub mod glob_no_match;
pub mod kubectl_gitops;
pub mod no_verify;
pub mod path_operand_missing;
pub mod persisted_output_dump;
pub mod pipe_to_tail;
pub mod poll_blank_verdict;
pub mod push_preflight;
pub mod release_tag_push;
pub mod sed_in_place;
pub mod stale_base;
pub mod stat_bsd_format;
pub mod stdin_hang;
pub mod tag_after_commit;
pub mod tool_shell;
pub mod unsplit_expansion;
pub mod whole_file_dump;
pub mod worktree_isolation;
pub mod worktree_remove_force;
#[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,
Routine,
}
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,
pub background: bool,
pub timeout_ms: Option<u64>,
}
impl Context<'_> {
pub fn timeout_ms(&self) -> u64 {
self.timeout_ms.unwrap_or(120_000)
}
pub fn cwd_at(&self, at: usize) -> std::path::PathBuf {
let mut dir = self.cwd.to_path_buf();
let asking = self
.parsed
.clauses()
.iter()
.find(|c| c.at == at)
.and_then(|c| c.nested);
for cmd in self.parsed.clauses() {
if cmd.at >= at {
continue;
}
if cmd.program() != Some("cd") {
continue;
}
if cmd.nested.is_some() && cmd.nested != asking {
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,
forge_merge_by_hand::RULE,
forge_status_stale_row::RULE,
no_verify::RULE,
git_add_broad::RULE,
stale_base::RULE,
push_preflight::RULE,
foreground_poll::RULE,
sed_in_place::RULE,
kubectl_gitops::RULE,
tag_after_commit::RULE,
release_tag_push::RULE,
worktree_remove_force::RULE,
amend_pushed::RULE,
branch_force_delete::RULE,
poll_blank_verdict::RULE,
worktree_isolation::RULE,
stdin_hang::RULE,
glob_in_flag_value::RULE,
glob_no_match::RULE,
equals_separator::RULE,
unsplit_expansion::RULE,
path_operand_missing::RULE,
stat_bsd_format::RULE,
whole_file_dump::RULE,
persisted_output_dump::RULE,
file_reread::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_cd_inside_a_substitution_moves_only_that_substitution() {
let parsed = lex(
"cd /tmp/here && echo $(cd /tmp/sub && git status) && git worktree add ../x -b feat/y",
);
let ctx = Context {
cwd: std::path::Path::new("/session"),
parsed: &parsed,
background: false,
timeout_ms: None,
};
assert_eq!(
ctx.cwd_at(at_of(&parsed, "worktree")),
std::path::PathBuf::from("/tmp/here"),
"the subshell's cd did not leak out"
);
assert_eq!(
ctx.cwd_at(at_of(&parsed, "status")),
std::path::PathBuf::from("/tmp/sub"),
"the line's cd reached in, then the subshell's own applied"
);
}
#[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,
background: false,
timeout_ms: None,
};
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,
background: false,
timeout_ms: None,
};
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,
background: false,
timeout_ms: None,
};
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,
background: false,
timeout_ms: None,
};
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,
background: false,
timeout_ms: None,
};
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"));
}
}