use crate::intent_guard::{self, ActionKind, Intent};
use crate::tasks::{self, TaskStatus};
use std::path::PathBuf;
pub struct HintContext<'a> {
pub intent: Intent,
pub action_kinds: &'a [ActionKind],
pub step_num: usize,
pub mcp_servers: &'a [&'a str],
}
pub trait HintSource: Send + Sync {
fn hints(&self, ctx: &HintContext) -> Vec<String>;
}
pub struct PatternHints;
pub struct ToolHints;
pub struct WorkflowHints;
pub struct TaskHints {
pub tasks_dir: PathBuf,
}
impl TaskHints {
pub fn new(project_root: &std::path::Path) -> Self {
Self {
tasks_dir: project_root.to_path_buf(),
}
}
}
impl HintSource for PatternHints {
fn hints(&self, ctx: &HintContext) -> Vec<String> {
pattern_hints(ctx)
}
}
impl HintSource for ToolHints {
fn hints(&self, ctx: &HintContext) -> Vec<String> {
tool_hints(ctx)
}
}
impl HintSource for WorkflowHints {
fn hints(&self, ctx: &HintContext) -> Vec<String> {
workflow_hints(ctx)
}
}
impl HintSource for TaskHints {
fn hints(&self, _ctx: &HintContext) -> Vec<String> {
let all_tasks = tasks::load_tasks(&self.tasks_dir);
let active: Vec<_> = all_tasks
.iter()
.filter(|t| matches!(t.status, TaskStatus::InProgress | TaskStatus::Blocked))
.collect();
if active.is_empty() {
return vec![];
}
let total = all_tasks.len();
let done = all_tasks
.iter()
.filter(|t| t.status == TaskStatus::Done)
.count();
vec![format!(
"TASKS [{}/{}]: {}",
done,
total,
active
.iter()
.map(|t| format!("#{} {}", t.id, t.title))
.collect::<Vec<_>>()
.join(", ")
)]
}
}
pub fn default_sources() -> Vec<Box<dyn HintSource>> {
vec![
Box::new(PatternHints),
Box::new(ToolHints),
Box::new(WorkflowHints),
]
}
pub fn default_sources_with_tasks(project_root: &std::path::Path) -> Vec<Box<dyn HintSource>> {
vec![
Box::new(PatternHints),
Box::new(ToolHints),
Box::new(WorkflowHints),
Box::new(TaskHints::new(project_root)),
]
}
pub fn collect_hints<A>(
ctx: &HintContext,
actions: &[A],
classify: impl Fn(&A) -> ActionKind,
sources: &[Box<dyn HintSource>],
) -> Vec<String> {
let mut hints = Vec::new();
hints.extend(intent_guard::guard_step(ctx.intent, actions, &classify));
for source in sources {
hints.extend(source.hints(ctx));
}
let mut seen = Vec::new();
hints.retain(|h| {
if seen.contains(h) {
false
} else {
seen.push(h.clone());
true
}
});
hints
}
fn pattern_hints(ctx: &HintContext) -> Vec<String> {
let mut hints = Vec::new();
let has_write = ctx
.action_kinds
.iter()
.any(|k| matches!(k, ActionKind::Write));
if has_write && ctx.step_num == 1 {
hints.push(
"Consider reading existing files before writing to avoid overwriting important code."
.into(),
);
}
hints
}
fn tool_hints(ctx: &HintContext) -> Vec<String> {
let mut hints = Vec::new();
let has_search = ctx
.action_kinds
.iter()
.any(|k| matches!(k, ActionKind::Read));
if has_search && ctx.mcp_servers.contains(&"codegraph") {
if ctx.step_num <= 2 {
hints.push(
"codegraph MCP is available — project_code_search may be more accurate than grep."
.into(),
);
}
}
hints
}
fn workflow_hints(ctx: &HintContext) -> Vec<String> {
let mut hints = Vec::new();
let has_write = ctx
.action_kinds
.iter()
.any(|k| matches!(k, ActionKind::Write));
if has_write {
hints.push("Remember to run tests after writing code to verify changes.".into());
}
let has_execute = ctx
.action_kinds
.iter()
.any(|k| matches!(k, ActionKind::Execute));
if has_execute && ctx.step_num > 5 {
hints.push("Consider committing progress if you haven't already.".into());
}
hints
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx_default<'a>() -> HintContext<'a> {
HintContext {
intent: Intent::Auto,
action_kinds: &[],
step_num: 1,
mcp_servers: &[],
}
}
#[test]
fn no_hints_on_read_only() {
let ctx = ctx_default();
let actions: Vec<ActionKind> = vec![ActionKind::Read];
let sources = default_sources();
let hints = collect_hints(&ctx, &actions, |k| *k, &sources);
assert!(hints.is_empty());
}
#[test]
fn write_on_step_1_gets_read_reminder() {
let ctx = HintContext {
action_kinds: &[ActionKind::Write],
step_num: 1,
..ctx_default()
};
let hints = pattern_hints(&ctx);
assert!(hints.iter().any(|h| h.contains("reading existing files")));
}
#[test]
fn write_gets_tdd_reminder() {
let ctx = HintContext {
action_kinds: &[ActionKind::Write],
..ctx_default()
};
let hints = workflow_hints(&ctx);
assert!(hints.iter().any(|h| h.contains("tests")));
}
#[test]
fn mcp_suggestion() {
let ctx = HintContext {
action_kinds: &[ActionKind::Read],
mcp_servers: &["codegraph"],
step_num: 1,
..ctx_default()
};
let hints = tool_hints(&ctx);
assert!(hints.iter().any(|h| h.contains("codegraph")));
}
#[test]
fn no_mcp_suggestion_on_late_steps() {
let ctx = HintContext {
action_kinds: &[ActionKind::Read],
mcp_servers: &["codegraph"],
step_num: 5,
..ctx_default()
};
let hints = tool_hints(&ctx);
assert!(hints.is_empty());
}
#[test]
fn git_reminder_on_late_execute() {
let ctx = HintContext {
action_kinds: &[ActionKind::Execute],
step_num: 7,
..ctx_default()
};
let hints = workflow_hints(&ctx);
assert!(hints.iter().any(|h| h.contains("committing")));
}
#[test]
fn dedup_in_collect() {
let actions = vec![ActionKind::Write, ActionKind::Write];
let ctx = HintContext {
intent: Intent::Ask,
action_kinds: &[ActionKind::Write],
step_num: 1,
..ctx_default()
};
let sources = default_sources();
let hints = collect_hints(&ctx, &actions, |k| *k, &sources);
let unique_count = hints.len();
let mut deduped = hints.clone();
deduped.sort();
deduped.dedup();
assert_eq!(unique_count, deduped.len());
}
}