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()
);
}
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(tag:&str) -> Self {
let path = std::env::temp_dir().join(format!(
"aphrodite-directives-test-{tag}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
fn path(&self) -> std::path::PathBuf { self.0.clone() }
}
impl Drop for TempDir {
fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); }
}
#[test]
fn test_load_directives_reads_md_files_from_dir() {
let dir = TempDir::new("basic");
std::fs::write(dir.path().join("focus.md"), "# focus\nstay concise").unwrap();
std::fs::write(dir.path().join("explore.md"), "# explore\nlook around").unwrap();
let loaded = load_directives(&dir.path());
assert_eq!(loaded.len(), 2);
assert_eq!(loaded["focus"].content, "# focus\nstay concise");
assert_eq!(loaded["explore"].content, "# explore\nlook around");
}
#[test]
fn test_load_directives_skips_non_md_files() {
let dir = TempDir::new("skip-non-md");
std::fs::write(dir.path().join("focus.md"), "keep me").unwrap();
std::fs::write(dir.path().join("README.txt"), "not a directive").unwrap();
std::fs::write(dir.path().join("notes"), "no extension at all").unwrap();
let loaded = load_directives(&dir.path());
assert_eq!(loaded.len(), 1);
assert!(loaded.contains_key("focus"));
}
#[test]
fn test_load_directives_missing_dir_returns_empty() {
let missing = std::env::temp_dir().join("aphrodite-directives-test-does-not-exist");
let loaded = load_directives(&missing);
assert!(loaded.is_empty());
}
#[test]
fn test_load_directives_truncates_at_max_chars_with_ellipsis() {
let dir = TempDir::new("truncate");
let oversized = "x".repeat(MAX_DIRECTIVE_CHARS + 500);
std::fs::write(dir.path().join("huge.md"), &oversized).unwrap();
let loaded = load_directives(&dir.path());
let content = &loaded["huge"].content;
assert_eq!(content.chars().count(), MAX_DIRECTIVE_CHARS + 1);
assert!(content.ends_with('…'));
}
#[test]
fn test_load_directives_under_cap_is_not_truncated() {
let dir = TempDir::new("under-cap");
let small = "short directive body";
std::fs::write(dir.path().join("small.md"), small).unwrap();
let loaded = load_directives(&dir.path());
assert_eq!(loaded["small"].content, small);
assert!(!loaded["small"].content.ends_with('…'));
}
}