use std::{
collections::HashMap,
path::PathBuf,
};
#[derive(Debug, Clone)]
pub struct Directive {
pub name: String,
pub content: String,
}
pub const MAX_DIRECTIVE_CHARS: usize = 2000;
pub const MAX_COMBINED_CHARS: usize = 4000;
pub fn load_directives(dir: &PathBuf) -> HashMap<String, Directive> {
let mut directives = HashMap::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return directives;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e != "md").unwrap_or(true) {
continue;
}
let Some(name) = path.file_stem().and_then(|n| n.to_str()) else {
continue;
};
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let content = if content.len() > MAX_DIRECTIVE_CHARS {
let trunc: String = content.chars().take(MAX_DIRECTIVE_CHARS).collect();
format!("{}…", trunc)
} else {
content
};
directives.insert(name.to_string(), Directive {
name: name.to_string(),
content,
});
}
directives
}
pub fn build_directive_context(
all: &HashMap<String, Directive>,
active: &[String],
) -> String {
if active.is_empty() {
return String::new();
}
let names: Vec<&str> = active.iter().map(|s| s.as_str()).collect();
let mut out = format!("[directives: {}]\n", names.join(", "));
for name in active {
if let Some(d) = all.get(name) {
out.push_str(&format!("{}:\n", d.name));
for line in d.content.lines() {
let line = line.trim_start_matches('#').trim();
if !line.is_empty() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
}
}
if out.len() > MAX_COMBINED_CHARS {
let trunc: String = out.chars().take(MAX_COMBINED_CHARS).collect();
out = format!("{}…\n", trunc);
}
out
}
pub fn handle_action(
state: &mut crate::state::AphroditeState,
action: &str,
name: &str,
) -> serde_json::Value {
match action {
"list" => serde_json::json!({
"available": state.directives.keys().collect::<Vec<&String>>(),
"active": &state.active_directives,
}),
"swap" => {
if state.directives.contains_key(name) {
state.active_directives = vec![name.to_string()];
serde_json::json!({"swapped": name, "active": &state.active_directives})
} else {
serde_json::json!({"error": format!("unknown directive: {}", name)})
}
}
"add" => {
if state.directives.contains_key(name) && !state.active_directives.contains(&name.to_string()) {
state.active_directives.push(name.to_string());
}
serde_json::json!({"active": &state.active_directives})
}
"remove" => {
state.active_directives.retain(|d| d != name);
serde_json::json!({"active": &state.active_directives})
}
"reset" => {
state.active_directives.clear();
serde_json::json!({"active": &state.active_directives})
}
_ => serde_json::json!({"error": format!("unknown action: {} (use list|swap|add|remove|reset)", action)}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_empty() {
let all = HashMap::new();
let context = build_directive_context(&all, &[]);
assert!(context.is_empty());
}
#[test]
fn test_build_with_active() {
let mut all = HashMap::new();
all.insert("focus".into(), Directive {
name: "focus".into(),
content: "stay concise\nuse 1-2 tools".into(),
});
let context = build_directive_context(&all, &["focus".into()]);
assert!(context.contains("[directives: focus]"));
assert!(context.contains("focus:\n"));
assert!(context.contains("stay concise"));
assert!(context.contains("use 1-2 tools"));
}
#[test]
fn test_build_with_active_injects_full_body_not_just_first_line() {
let mut all = HashMap::new();
all.insert("focus".into(), Directive {
name:"focus".into(),
content:"# focus - stay targeted, minimal tool usage\n\n\
# Each turn: use at most 1-2 tools.\n\n\
- One primary action per turn\n\
- Prefer aphrodite_retrieve over re-reading"
.into(),
});
let context = build_directive_context(&all, &["focus".into()]);
assert!(context.contains("Each turn: use at most 1-2 tools."), "body line missing: {context}");
assert!(context.contains("One primary action per turn"), "bullet line missing: {context}");
assert!(!context.contains('#'), "leading # markers must be stripped: {context}");
}
#[test]
fn test_handle_action_all_actions_and_unknown() {
let mut state = crate::state::AphroditeState::default();
state.directives.insert("focus".into(), Directive { name:"focus".into(), content:"stay focused".into() });
let r = handle_action(&mut state, "list", "");
assert_eq!(r["available"], serde_json::json!(["focus"]));
assert_eq!(r["active"], serde_json::json!([]));
let r = handle_action(&mut state, "swap", "focus");
assert_eq!(r["swapped"], "focus");
assert_eq!(state.active_directives, vec!["focus".to_string()]);
let r = handle_action(&mut state, "swap", "nonexistent");
assert!(r["error"].as_str().unwrap().contains("unknown directive"));
let r = handle_action(&mut state, "remove", "focus");
assert_eq!(r["active"], serde_json::json!([]));
let r = handle_action(&mut state, "add", "focus");
assert_eq!(r["active"], serde_json::json!(["focus"]));
let r = handle_action(&mut state, "reset", "");
assert_eq!(r["active"], serde_json::json!([]));
assert!(state.active_directives.is_empty());
let r = handle_action(&mut state, "bogus", "");
assert!(r["error"].as_str().unwrap().contains("unknown action"));
}
#[test]
fn test_build_with_active_caps_combined_output() {
let mut all = HashMap::new();
all.insert("big".into(), Directive { name:"big".into(), content:"x".repeat(MAX_DIRECTIVE_CHARS) });
all.insert("also-big".into(), Directive { name:"also-big".into(), content:"y".repeat(MAX_DIRECTIVE_CHARS) });
let context = build_directive_context(&all, &["big".into(), "also-big".into()]);
assert!(context.len() <= MAX_COMBINED_CHARS + 10, "combined output must respect the cap: {} chars", context.len());
}
}