use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct SystemPrompt {
pub cacheable: String,
pub dynamic: String,
}
impl SystemPrompt {
pub fn combined(&self) -> String {
if self.dynamic.is_empty() {
self.cacheable.clone()
} else {
format!("{}\n\n---\n\n{}", self.cacheable, self.dynamic)
}
}
}
#[derive(Debug, Clone)]
pub struct PromptIdentity {
pub tier: String,
pub subject: Option<String>,
}
pub fn build_identity_section(identity: Option<&PromptIdentity>) -> String {
match identity {
Some(id) => {
let subject_line = id
.subject
.as_deref()
.map(|s| format!("\n**Subject**: {s}"))
.unwrap_or_default();
format!(
"## Identity\n\
**Agent**: arcan shell\n\
**Tier**: {}{}",
id.tier, subject_line
)
}
None => "## Identity\n\
**Agent**: arcan shell\n\
**Tier**: anonymous local agent"
.to_string(),
}
}
pub fn build_system_prompt(
workspace: &Path,
provider_name: &str,
model_name: &str,
memory_dir: &Path,
workspace_context: Option<&str>,
skill_catalog: Option<&str>,
claude_md_content: Option<&str>,
) -> SystemPrompt {
build_system_prompt_with_identity(
workspace,
provider_name,
model_name,
memory_dir,
workspace_context,
skill_catalog,
claude_md_content,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn build_system_prompt_with_identity(
workspace: &Path,
provider_name: &str,
model_name: &str,
memory_dir: &Path,
workspace_context: Option<&str>,
skill_catalog: Option<&str>,
claude_md_content: Option<&str>,
identity: Option<&PromptIdentity>,
) -> SystemPrompt {
let mut cacheable_sections = Vec::new();
cacheable_sections.push(build_role_section());
cacheable_sections.push(build_identity_section(identity));
cacheable_sections.push(build_environment_section(
workspace,
provider_name,
model_name,
));
if let Some(instructions) = claude_md_content
&& !instructions.is_empty()
{
cacheable_sections.push(format!("# Project Instructions\n\n{instructions}"));
}
cacheable_sections.push(build_guidelines_section());
let cacheable = cacheable_sections.join("\n\n---\n\n");
let mut dynamic_sections = Vec::new();
if let Some(git) = build_git_section(workspace) {
dynamic_sections.push(git);
}
if let Some(memory) = build_memory_section(memory_dir) {
dynamic_sections.push(memory);
}
if let Some(context) = workspace_context
&& !context.is_empty()
{
dynamic_sections.push(format!("# Workspace Context\n\n{context}"));
}
if let Some(catalog) = skill_catalog
&& !catalog.is_empty()
{
dynamic_sections.push(format!("# Available Skills\n\n{catalog}"));
}
let dynamic = if dynamic_sections.is_empty() {
String::new()
} else {
dynamic_sections.join("\n\n---\n\n")
};
SystemPrompt { cacheable, dynamic }
}
pub fn build_role_section() -> String {
"# System\n\n\
You are an AI coding assistant powered by Arcan, the Life Agent OS runtime. \
You help users with software engineering tasks by reading files, editing code, \
running commands, and searching codebases. Be concise and direct. \
Read files before editing them. Use tools to explore rather than guessing. \
Follow existing code style and conventions."
.to_string()
}
pub fn build_environment_section(workspace: &Path, provider: &str, model: &str) -> String {
let cwd = workspace.display();
let platform = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let date = chrono::Local::now().format("%Y-%m-%d");
let shell = std::env::var("SHELL").unwrap_or_else(|_| "unknown".into());
format!(
"# Environment\n\n\
- Working directory: {cwd}\n\
- Platform: {platform} ({arch})\n\
- Shell: {shell}\n\
- Date: {date}\n\
- Provider: {provider}\n\
- Model: {model}"
)
}
pub fn build_git_section(workspace: &Path) -> Option<String> {
let branch = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(workspace)
.output()
.ok()?;
if !branch.status.success() {
return None;
}
let branch_name = String::from_utf8_lossy(&branch.stdout).trim().to_string();
let status = std::process::Command::new("git")
.args(["status", "--short"])
.current_dir(workspace)
.output()
.ok()?;
let status_text = String::from_utf8_lossy(&status.stdout).trim().to_string();
let status_display = if status_text.is_empty() {
"Clean".to_string()
} else if status_text.len() > 500 {
format!("{}...(truncated)", &status_text[..500])
} else {
status_text
};
let log = std::process::Command::new("git")
.args(["log", "--oneline", "-5"])
.current_dir(workspace)
.output()
.ok()?;
let log_text = String::from_utf8_lossy(&log.stdout).trim().to_string();
Some(format!(
"# Git Context\n\n\
- Branch: {branch_name}\n\
- Status:\n```\n{status_display}\n```\n\
- Recent commits:\n```\n{log_text}\n```"
))
}
pub fn load_project_instructions(workspace: &Path) -> Option<String> {
let mut contents = Vec::new();
load_file_if_exists(workspace, "CLAUDE.md", &mut contents);
load_file_if_exists(workspace, "AGENTS.md", &mut contents);
load_file_if_exists(workspace, ".claude/CLAUDE.md", &mut contents);
load_rules_dir(workspace, ".claude/rules", &mut contents);
if let Some(parent) = workspace.parent() {
let parent_claude = parent.join("CLAUDE.md");
if parent_claude.exists()
&& parent_claude != workspace.join("CLAUDE.md")
&& let Ok(content) = std::fs::read_to_string(&parent_claude)
&& !content.trim().is_empty()
{
contents.push(format!(
"<!-- from {} -->\n{}",
parent_claude.display(),
content
));
}
}
for doc_file in &["docs/STATUS.md", "docs/ARCHITECTURE.md", "docs/ROADMAP.md"] {
let path = workspace.join(doc_file);
if path.exists()
&& let Ok(content) = std::fs::read_to_string(&path)
{
let trimmed = content.trim();
if !trimmed.is_empty() {
let truncated = if trimmed.len() > 2000 {
format!(
"{}\n\n... (truncated, {} total chars — use read_file for full content)",
&trimmed[..2000],
trimmed.len()
)
} else {
trimmed.to_string()
};
contents.push(format!("<!-- from {doc_file} -->\n{truncated}"));
}
}
}
let policy_path = workspace.join(".control/policy.yaml");
if policy_path.exists()
&& let Ok(content) = std::fs::read_to_string(&policy_path)
&& !content.trim().is_empty()
{
contents.push(format!(
"<!-- Control policy (.control/policy.yaml) -->\n```yaml\n{}\n```",
content.trim()
));
}
if contents.is_empty() {
None
} else {
Some(contents.join("\n\n"))
}
}
pub fn load_claude_md(workspace: &Path) -> Option<String> {
load_project_instructions(workspace)
}
fn load_file_if_exists(workspace: &Path, relative: &str, contents: &mut Vec<String>) {
let path = workspace.join(relative);
if path.exists()
&& let Ok(content) = std::fs::read_to_string(&path)
&& !content.trim().is_empty()
{
contents.push(content);
}
}
fn load_rules_dir(workspace: &Path, relative: &str, contents: &mut Vec<String>) {
let rules_dir = workspace.join(relative);
if rules_dir.is_dir()
&& let Ok(entries) = std::fs::read_dir(&rules_dir)
{
let mut rule_files: Vec<_> = entries
.flatten()
.filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
.collect();
rule_files.sort_by_key(std::fs::DirEntry::path);
for entry in rule_files {
if let Ok(content) = std::fs::read_to_string(entry.path())
&& !content.trim().is_empty()
{
contents.push(content);
}
}
}
}
pub fn build_memory_section(memory_dir: &Path) -> Option<String> {
if !memory_dir.exists() {
return None;
}
let index_path = memory_dir.join("MEMORY.md");
if index_path.exists()
&& let Ok(content) = std::fs::read_to_string(&index_path)
&& !content.trim().is_empty()
{
return Some(format!("# Agent Memory\n\n{content}"));
}
let entries = std::fs::read_dir(memory_dir).ok()?;
let mut sections = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if path.file_name().and_then(|n| n.to_str()) == Some("MEMORY.md") {
continue;
}
let key = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
if let Ok(content) = std::fs::read_to_string(&path)
&& !content.trim().is_empty()
{
sections.push(format!("## {key}\n{content}"));
}
}
if sections.is_empty() {
return None;
}
sections.sort();
Some(format!(
"# Agent Memory (cross-session)\n\n{}",
sections.join("\n\n")
))
}
const MEMORY_INDEX_MAX_LINES: usize = 200;
const MEMORY_INDEX_MAX_BYTES: usize = 25_000;
pub fn generate_memory_index(memory_dir: &Path) -> String {
let mut sections: BTreeMap<String, Vec<String>> = BTreeMap::new();
let Ok(entries) = std::fs::read_dir(memory_dir) else {
return String::from("# Memory Index\n");
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if path.file_name().and_then(|n| n.to_str()) == Some("MEMORY.md") {
continue;
}
let key = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let content = std::fs::read_to_string(&path).unwrap_or_default();
let mem_type = extract_frontmatter_type(&content).unwrap_or_else(|| "general".to_string());
let description = extract_first_content_line(&content);
sections
.entry(mem_type)
.or_default()
.push(format!("- [{}]({}.md) — {}", key, key, description));
}
let mut index = String::from("# Memory Index\n\n");
for (section, entries) in §ions {
index.push_str(&format!("## {}\n", capitalize(section)));
for entry in entries {
index.push_str(entry);
index.push('\n');
}
index.push('\n');
}
let lines: Vec<&str> = index.lines().collect();
if lines.len() > MEMORY_INDEX_MAX_LINES {
index = lines[..MEMORY_INDEX_MAX_LINES].join("\n");
index.push_str("\n\n... (truncated, showing first 200 entries)\n");
}
if index.len() > MEMORY_INDEX_MAX_BYTES {
index.truncate(MEMORY_INDEX_MAX_BYTES);
index.push_str("\n\n... (truncated at 25KB)\n");
}
index
}
pub fn write_memory_index(memory_dir: &Path) {
let _ = std::fs::create_dir_all(memory_dir);
let index = generate_memory_index(memory_dir);
let index_path = memory_dir.join("MEMORY.md");
let _ = std::fs::write(&index_path, &index);
}
fn extract_frontmatter_type(content: &str) -> Option<String> {
if !content.starts_with("---") {
return None;
}
let end = content[3..].find("---")?;
let frontmatter = &content[3..3 + end];
for line in frontmatter.lines() {
let trimmed = line.trim();
if let Some(value) = trimmed.strip_prefix("type:") {
return Some(value.trim().to_string());
}
}
None
}
fn extract_first_content_line(content: &str) -> String {
let body = if let Some(after_prefix) = content.strip_prefix("---") {
after_prefix
.find("---")
.map(|i| &after_prefix[i + 3..])
.unwrap_or(content)
} else {
content
};
body.lines()
.map(str::trim)
.find(|l| !l.is_empty() && !l.starts_with('#'))
.unwrap_or("(no description)")
.chars()
.take(120)
.collect()
}
fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
}
pub fn build_bare_prompt(workspace: &Path, provider: &str, model: &str) -> String {
let cwd = workspace.display();
let platform = std::env::consts::OS;
let date = chrono::Local::now().format("%Y-%m-%d");
format!(
"You are an AI coding assistant running on {platform}. \
Help with software engineering tasks and answer questions. \
Be concise and direct.\n\n\
Workspace: {cwd} | Date: {date} | Provider: {provider} | Model: {model}\n\n\
You have these capabilities (available as tools when needed):\n\
- read_file: Read file contents from the workspace\n\
- write_file: Create or overwrite a file\n\
- edit_file: Make targeted edits to existing files\n\
- bash: Run shell commands\n\
- glob: Find files by pattern\n\
- grep: Search file contents with regex\n\n\
When answering questions directly, respond with plain text. \
Only suggest using tools when the user needs to interact with files or run commands."
)
}
pub fn build_guidelines_section() -> String {
"# Guidelines\n\n\
- Read files before editing them\n\
- Use tools to explore the codebase rather than guessing\n\
- Be concise and direct in responses\n\
- Follow existing code style and conventions\n\
- Prefer editing existing files over creating new ones\n\
- Do not add features beyond what was asked"
.to_string()
}
pub fn build_peer_context_section(messages: &[String]) -> Option<String> {
if messages.is_empty() {
return None;
}
let mut section = String::from("# Peer Activity\n\nRecent messages from other agents:\n\n");
for msg in messages.iter().take(10) {
section.push_str(&format!("- {msg}\n"));
}
Some(section)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_build_system_prompt_includes_all_sections() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let memory_dir = workspace.join(".arcan/memory");
fs::create_dir_all(&memory_dir).unwrap();
fs::write(memory_dir.join("notes.md"), "Some notes here").unwrap();
let sp = build_system_prompt(
workspace,
"anthropic",
"claude-sonnet-4-5-20250929",
&memory_dir,
Some("- Peer session: explored workspace journal"),
Some("- skill_a: Does A\n- skill_b: Does B"),
Some("# My Project\n\nBuild fast."),
);
let prompt = sp.combined();
assert!(prompt.contains("# System"), "missing role section");
assert!(
prompt.contains("# Environment"),
"missing environment section"
);
assert!(
prompt.contains("# Project Instructions"),
"missing claude.md section"
);
assert!(prompt.contains("# Agent Memory"), "missing memory section");
assert!(
prompt.contains("# Workspace Context"),
"missing workspace context section"
);
assert!(
prompt.contains("# Available Skills"),
"missing skills section"
);
assert!(
prompt.contains("# Guidelines"),
"missing guidelines section"
);
assert!(prompt.contains("---"), "missing section separators");
}
#[test]
fn test_build_system_prompt_omits_empty_sections() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let memory_dir = workspace.join(".arcan/memory");
let sp = build_system_prompt(
workspace,
"mock",
"mock-model",
&memory_dir,
None,
None,
None,
);
let prompt = sp.combined();
assert!(prompt.contains("# System"));
assert!(prompt.contains("# Environment"));
assert!(prompt.contains("# Guidelines"));
assert!(
!prompt.contains("# Project Instructions"),
"should omit empty claude.md"
);
assert!(
!prompt.contains("# Agent Memory"),
"should omit missing memory"
);
assert!(
!prompt.contains("# Available Skills"),
"should omit empty skills"
);
}
#[test]
fn test_load_claude_md_from_workspace() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
fs::write(workspace.join("CLAUDE.md"), "# Instructions\nDo X.").unwrap();
let result = load_project_instructions(workspace);
assert!(result.is_some());
assert!(result.unwrap().contains("Do X."));
}
#[test]
fn test_load_agents_md() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
fs::write(workspace.join("AGENTS.md"), "# Agent Rules\nBe safe.").unwrap();
let result = load_project_instructions(workspace);
assert!(result.is_some());
assert!(result.unwrap().contains("Be safe."));
}
#[test]
fn test_load_both_claude_and_agents_md() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
fs::write(workspace.join("CLAUDE.md"), "Claude rules.").unwrap();
fs::write(workspace.join("AGENTS.md"), "Agent rules.").unwrap();
let result = load_project_instructions(workspace).unwrap();
assert!(result.contains("Claude rules."));
assert!(result.contains("Agent rules."));
}
#[test]
fn test_load_rules_dir() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let rules_dir = workspace.join(".claude/rules");
fs::create_dir_all(&rules_dir).unwrap();
fs::write(rules_dir.join("code-style.md"), "Use snake_case.").unwrap();
fs::write(rules_dir.join("testing.md"), "All code needs tests.").unwrap();
let result = load_project_instructions(workspace);
assert!(result.is_some());
let content = result.unwrap();
assert!(content.contains("Use snake_case."));
assert!(content.contains("All code needs tests."));
}
#[test]
fn test_load_docs_context() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let docs_dir = workspace.join("docs");
fs::create_dir_all(&docs_dir).unwrap();
fs::write(docs_dir.join("STATUS.md"), "# Status\n100% tests passing").unwrap();
fs::write(docs_dir.join("ARCHITECTURE.md"), "# Arch\nEvent-sourced.").unwrap();
let result = load_project_instructions(workspace).unwrap();
assert!(result.contains("100% tests passing"));
assert!(result.contains("Event-sourced."));
}
#[test]
fn test_load_control_policy() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let control_dir = workspace.join(".control");
fs::create_dir_all(&control_dir).unwrap();
fs::write(
control_dir.join("policy.yaml"),
"gates:\n - name: G1\n blocking: true",
)
.unwrap();
let result = load_project_instructions(workspace).unwrap();
assert!(result.contains("gates:"));
assert!(result.contains("blocking: true"));
}
#[test]
fn test_load_empty_workspace_returns_none() {
let tmp = TempDir::new().unwrap();
let result = load_project_instructions(tmp.path());
assert!(result.is_none());
}
#[test]
fn test_git_section_in_repo() {
let workspace = std::env::current_dir().unwrap();
let result = build_git_section(&workspace);
if let Some(git_section) = result {
assert!(git_section.contains("# Git Context"));
assert!(git_section.contains("Branch:"));
}
}
#[test]
fn test_git_section_non_repo() {
let tmp = TempDir::new().unwrap();
let result = build_git_section(tmp.path());
assert!(result.is_none(), "non-repo dir should return None");
}
#[test]
fn test_environment_section() {
let tmp = TempDir::new().unwrap();
let section = build_environment_section(tmp.path(), "anthropic", "claude-sonnet");
assert!(section.contains("# Environment"));
assert!(section.contains("Platform:"));
assert!(section.contains("Provider: anthropic"));
assert!(section.contains("Model: claude-sonnet"));
assert!(section.contains("Date:"));
}
#[test]
fn test_memory_section() {
let tmp = TempDir::new().unwrap();
let memory_dir = tmp.path().join("memory");
fs::create_dir_all(&memory_dir).unwrap();
fs::write(memory_dir.join("notes.md"), "Remember this.").unwrap();
let result = build_memory_section(&memory_dir);
assert!(result.is_some());
let content = result.unwrap();
assert!(content.contains("# Agent Memory"));
assert!(content.contains("Remember this."));
}
#[test]
fn test_memory_section_empty_dir() {
let tmp = TempDir::new().unwrap();
let memory_dir = tmp.path().join("memory");
fs::create_dir_all(&memory_dir).unwrap();
let result = build_memory_section(&memory_dir);
assert!(result.is_none(), "empty memory dir should return None");
}
#[test]
fn test_memory_section_missing_dir() {
let tmp = TempDir::new().unwrap();
let memory_dir = tmp.path().join("nonexistent");
let result = build_memory_section(&memory_dir);
assert!(result.is_none(), "missing memory dir should return None");
}
#[test]
fn test_role_section_content() {
let role = build_role_section();
assert!(role.contains("Arcan"));
assert!(role.contains("Life Agent OS"));
}
#[test]
fn test_guidelines_section_content() {
let guidelines = build_guidelines_section();
assert!(guidelines.contains("Read files before editing"));
assert!(guidelines.contains("Do not add features beyond what was asked"));
}
#[test]
fn test_load_combines_all_sources() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
fs::write(workspace.join("CLAUDE.md"), "Root instructions.").unwrap();
fs::write(workspace.join("AGENTS.md"), "Agent boundaries.").unwrap();
let dot_claude = workspace.join(".claude");
fs::create_dir_all(&dot_claude).unwrap();
fs::write(dot_claude.join("CLAUDE.md"), "Dot-claude instructions.").unwrap();
let rules_dir = dot_claude.join("rules");
fs::create_dir_all(&rules_dir).unwrap();
fs::write(rules_dir.join("style.md"), "Style rules.").unwrap();
let docs = workspace.join("docs");
fs::create_dir_all(&docs).unwrap();
fs::write(docs.join("STATUS.md"), "All green.").unwrap();
let control = workspace.join(".control");
fs::create_dir_all(&control).unwrap();
fs::write(control.join("policy.yaml"), "version: 1").unwrap();
let result = load_project_instructions(workspace).unwrap();
assert!(result.contains("Root instructions."));
assert!(result.contains("Agent boundaries."));
assert!(result.contains("Dot-claude instructions."));
assert!(result.contains("Style rules."));
assert!(result.contains("All green."));
assert!(result.contains("version: 1"));
}
#[test]
fn test_backward_compat_load_claude_md() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
fs::write(workspace.join("CLAUDE.md"), "Legacy call.").unwrap();
let result = load_claude_md(workspace);
assert!(result.is_some());
assert!(result.unwrap().contains("Legacy call."));
}
#[test]
fn test_prompt_available_from_core() {
let _ = build_system_prompt
as fn(
&Path,
&str,
&str,
&Path,
Option<&str>,
Option<&str>,
Option<&str>,
) -> SystemPrompt;
let _ = build_system_prompt_with_identity
as fn(
&Path,
&str,
&str,
&Path,
Option<&str>,
Option<&str>,
Option<&str>,
Option<&PromptIdentity>,
) -> SystemPrompt;
let _ = build_identity_section as fn(Option<&PromptIdentity>) -> String;
let _ = build_git_section as fn(&Path) -> Option<String>;
let _ = load_project_instructions as fn(&Path) -> Option<String>;
let _ = build_environment_section as fn(&Path, &str, &str) -> String;
let _ = build_memory_section as fn(&Path) -> Option<String>;
let _ = build_role_section as fn() -> String;
let _ = build_guidelines_section as fn() -> String;
let _ = build_bare_prompt as fn(&Path, &str, &str) -> String;
let _ = generate_memory_index as fn(&Path) -> String;
let _ = write_memory_index as fn(&Path);
}
#[test]
fn test_identity_section_with_full_identity() {
let id = PromptIdentity {
tier: "pro".to_string(),
subject: Some("user@example.com".to_string()),
};
let section = build_identity_section(Some(&id));
assert!(section.contains("## Identity"));
assert!(section.contains("**Agent**: arcan shell"));
assert!(section.contains("**Tier**: pro"));
assert!(section.contains("**Subject**: user@example.com"));
}
#[test]
fn test_identity_section_without_subject() {
let id = PromptIdentity {
tier: "free".to_string(),
subject: None,
};
let section = build_identity_section(Some(&id));
assert!(section.contains("**Tier**: free"));
assert!(!section.contains("**Subject**"));
}
#[test]
fn test_identity_section_anonymous() {
let section = build_identity_section(None);
assert!(section.contains("## Identity"));
assert!(section.contains("anonymous local agent"));
}
#[test]
fn test_system_prompt_with_identity_includes_block() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let memory_dir = workspace.join(".arcan/memory");
let id = PromptIdentity {
tier: "enterprise".to_string(),
subject: Some("admin@corp.com".to_string()),
};
let sp = build_system_prompt_with_identity(
workspace,
"anthropic",
"claude-sonnet",
&memory_dir,
None,
None,
None,
Some(&id),
);
let combined = sp.combined();
assert!(combined.contains("## Identity"), "missing identity section");
assert!(
combined.contains("**Tier**: enterprise"),
"missing tier in identity"
);
assert!(
combined.contains("**Subject**: admin@corp.com"),
"missing subject in identity"
);
}
#[test]
fn test_system_prompt_without_identity_shows_anonymous() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let memory_dir = workspace.join(".arcan/memory");
let sp = build_system_prompt(workspace, "mock", "mock", &memory_dir, None, None, None);
let combined = sp.combined();
assert!(
combined.contains("anonymous local agent"),
"should show anonymous when no identity"
);
}
#[test]
fn test_generate_memory_index() {
let tmp = TempDir::new().unwrap();
let memory_dir = tmp.path().join("memory");
fs::create_dir_all(&memory_dir).unwrap();
fs::write(
memory_dir.join("project_notes.md"),
"Key architecture decisions for the project.",
)
.unwrap();
fs::write(
memory_dir.join("user_prefs.md"),
"---\ntype: user\n---\n# Preferences\nPrefers dark mode.",
)
.unwrap();
let index = generate_memory_index(&memory_dir);
assert!(index.contains("# Memory Index"), "missing header");
assert!(
index.contains("[project_notes]"),
"missing project_notes entry"
);
assert!(index.contains("[user_prefs]"), "missing user_prefs entry");
assert!(index.contains("## User"), "missing User section header");
assert!(
index.contains("## General"),
"missing General section header"
);
assert!(
index.contains("Key architecture decisions"),
"missing description from project_notes"
);
assert!(
index.contains("Prefers dark mode"),
"missing description from user_prefs"
);
}
#[test]
fn test_memory_index_skips_memory_md() {
let tmp = TempDir::new().unwrap();
let memory_dir = tmp.path().join("memory");
fs::create_dir_all(&memory_dir).unwrap();
fs::write(memory_dir.join("MEMORY.md"), "# Old index").unwrap();
fs::write(memory_dir.join("real_note.md"), "A real note.").unwrap();
let index = generate_memory_index(&memory_dir);
assert!(index.contains("[real_note]"));
assert!(!index.contains("[MEMORY]"));
}
#[test]
fn test_memory_index_caps_at_200_lines() {
let tmp = TempDir::new().unwrap();
let memory_dir = tmp.path().join("memory");
fs::create_dir_all(&memory_dir).unwrap();
for i in 0..250 {
fs::write(
memory_dir.join(format!("note_{i:03}.md")),
format!("Content for note {i}."),
)
.unwrap();
}
let index = generate_memory_index(&memory_dir);
let line_count = index.lines().count();
assert!(line_count <= 205, "expected <= 205 lines, got {line_count}");
assert!(
index.contains("truncated"),
"should contain truncation notice"
);
}
#[test]
fn test_memory_index_extracts_frontmatter_type() {
let tmp = TempDir::new().unwrap();
let memory_dir = tmp.path().join("memory");
fs::create_dir_all(&memory_dir).unwrap();
fs::write(
memory_dir.join("arch_notes.md"),
"---\ntype: project\ntags: [arch]\n---\n# Architecture\nEvent-sourced design.",
)
.unwrap();
fs::write(
memory_dir.join("tax_info.md"),
"---\ntype: user\n---\nColombian tax rules.",
)
.unwrap();
fs::write(
memory_dir.join("general_stuff.md"),
"Just some general notes without frontmatter.",
)
.unwrap();
let index = generate_memory_index(&memory_dir);
assert!(index.contains("## Project"), "missing Project section");
assert!(index.contains("## User"), "missing User section");
assert!(index.contains("## General"), "missing General section");
}
#[test]
fn test_write_memory_index_creates_file() {
let tmp = TempDir::new().unwrap();
let memory_dir = tmp.path().join("memory");
fs::create_dir_all(&memory_dir).unwrap();
fs::write(memory_dir.join("test.md"), "Test content.").unwrap();
write_memory_index(&memory_dir);
let index_path = memory_dir.join("MEMORY.md");
assert!(index_path.exists(), "MEMORY.md should be created");
let content = fs::read_to_string(&index_path).unwrap();
assert!(content.contains("# Memory Index"));
assert!(content.contains("[test]"));
}
#[test]
fn test_memory_section_prefers_index() {
let tmp = TempDir::new().unwrap();
let memory_dir = tmp.path().join("memory");
fs::create_dir_all(&memory_dir).unwrap();
fs::write(memory_dir.join("notes.md"), "Individual note.").unwrap();
write_memory_index(&memory_dir);
let section = build_memory_section(&memory_dir).unwrap();
assert!(
section.contains("Memory Index"),
"should prefer MEMORY.md index"
);
}
#[test]
fn test_system_prompt_struct_has_both_sections() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let memory_dir = workspace.join(".arcan/memory");
fs::create_dir_all(&memory_dir).unwrap();
fs::write(memory_dir.join("notes.md"), "Some notes.").unwrap();
let sp = build_system_prompt(
workspace,
"anthropic",
"claude-sonnet",
&memory_dir,
None,
Some("- skill_a: Does A"),
Some("Build fast."),
);
assert!(!sp.cacheable.is_empty(), "cacheable should not be empty");
assert!(!sp.dynamic.is_empty(), "dynamic should not be empty");
}
#[test]
fn test_cacheable_section_stable() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
fs::write(workspace.join("CLAUDE.md"), "Project rules.").unwrap();
let memory_dir = workspace.join(".arcan/memory");
let sp1 = build_system_prompt(
workspace,
"anthropic",
"claude-sonnet",
&memory_dir,
None,
None,
Some("Project rules."),
);
let sp2 = build_system_prompt(
workspace,
"anthropic",
"claude-sonnet",
&memory_dir,
None,
None,
Some("Project rules."),
);
assert_eq!(
sp1.cacheable, sp2.cacheable,
"cacheable section should be identical for same inputs"
);
}
#[test]
fn test_dynamic_section_changes_with_memory() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let memory_dir = workspace.join(".arcan/memory");
fs::create_dir_all(&memory_dir).unwrap();
let sp1 = build_system_prompt(
workspace,
"anthropic",
"claude-sonnet",
&memory_dir,
None,
None,
None,
);
fs::write(memory_dir.join("new_note.md"), "New insight.").unwrap();
let sp2 = build_system_prompt(
workspace,
"anthropic",
"claude-sonnet",
&memory_dir,
None,
None,
None,
);
assert_ne!(
sp1.dynamic, sp2.dynamic,
"dynamic section should change when memory files are added"
);
assert_eq!(
sp1.cacheable, sp2.cacheable,
"cacheable section should not change with memory"
);
}
#[test]
fn test_cacheable_contains_role_env_guidelines() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let memory_dir = workspace.join("memory");
let sp = build_system_prompt(
workspace,
"anthropic",
"claude-sonnet",
&memory_dir,
None,
None,
None,
);
assert!(
sp.cacheable.contains("# System"),
"cacheable should contain role"
);
assert!(
sp.cacheable.contains("# Environment"),
"cacheable should contain environment"
);
assert!(
sp.cacheable.contains("# Guidelines"),
"cacheable should contain guidelines"
);
}
#[test]
fn test_dynamic_contains_git_memory_skills() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let memory_dir = workspace.join(".arcan/memory");
fs::create_dir_all(&memory_dir).unwrap();
fs::write(memory_dir.join("notes.md"), "Remember.").unwrap();
let sp = build_system_prompt(
workspace,
"anthropic",
"claude-sonnet",
&memory_dir,
Some("- Session abc turn 3: Added memory_similar"),
Some("- skill_a"),
None,
);
assert!(
sp.dynamic.contains("# Agent Memory"),
"dynamic should contain memory"
);
assert!(
sp.dynamic.contains("# Workspace Context"),
"dynamic should contain workspace context"
);
assert!(
sp.dynamic.contains("# Available Skills"),
"dynamic should contain skills"
);
}
#[test]
fn test_backward_compat_combined() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let memory_dir = workspace.join(".arcan/memory");
fs::create_dir_all(&memory_dir).unwrap();
fs::write(memory_dir.join("notes.md"), "Some notes.").unwrap();
let sp = build_system_prompt(
workspace,
"anthropic",
"claude-sonnet",
&memory_dir,
None,
Some("- skill_a"),
Some("Project instructions."),
);
let combined = sp.combined();
assert!(combined.contains("# System"));
assert!(combined.contains("# Guidelines"));
assert!(combined.contains("# Agent Memory"));
assert!(combined.contains("# Available Skills"));
}
#[test]
fn test_combined_empty_dynamic() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path();
let memory_dir = workspace.join("nonexistent");
let sp = build_system_prompt(workspace, "mock", "mock", &memory_dir, None, None, None);
let combined = sp.combined();
assert_eq!(combined, sp.cacheable);
}
#[test]
fn test_extract_frontmatter_type_valid() {
let content = "---\ntype: project\ntags: [a, b]\n---\n# Title\nBody.";
assert_eq!(
extract_frontmatter_type(content),
Some("project".to_string())
);
}
#[test]
fn test_extract_frontmatter_type_missing() {
let content = "---\ntags: [a]\n---\nNo type field.";
assert_eq!(extract_frontmatter_type(content), None);
}
#[test]
fn test_extract_frontmatter_type_no_frontmatter() {
let content = "Just plain text.";
assert_eq!(extract_frontmatter_type(content), None);
}
#[test]
fn test_extract_first_content_line_with_frontmatter() {
let content = "---\ntype: user\n---\n# Heading\nFirst real line.";
assert_eq!(extract_first_content_line(content), "First real line.");
}
#[test]
fn test_extract_first_content_line_no_frontmatter() {
let content = "# Heading\nContent line.";
assert_eq!(extract_first_content_line(content), "Content line.");
}
#[test]
fn test_extract_first_content_line_empty() {
let content = "";
assert_eq!(extract_first_content_line(content), "(no description)");
}
#[test]
fn test_capitalize() {
assert_eq!(capitalize("general"), "General");
assert_eq!(capitalize("user"), "User");
assert_eq!(capitalize(""), "");
assert_eq!(capitalize("ALREADY"), "ALREADY");
}
#[test]
fn test_bare_prompt_is_compact() {
let tmp = TempDir::new().unwrap();
let prompt = build_bare_prompt(tmp.path(), "apfel", "apple-foundationmodel");
assert!(prompt.contains("AI coding assistant"), "missing role");
assert!(prompt.contains("Date:"), "missing date");
assert!(prompt.contains("Provider: apfel"), "missing provider");
assert!(
prompt.contains("Model: apple-foundationmodel"),
"missing model"
);
assert!(prompt.contains("read_file"), "missing read_file tool");
assert!(prompt.contains("bash"), "missing bash tool");
assert!(prompt.contains("grep"), "missing grep tool");
assert!(
!prompt.contains("# Project Instructions"),
"bare prompt should not have project instructions"
);
assert!(
!prompt.contains("# Agent Memory"),
"bare prompt should not have memory"
);
assert!(
!prompt.contains("# Git Context"),
"bare prompt should not have git context"
);
assert!(
prompt.len() < 1200,
"bare prompt too long: {} chars (target <1200)",
prompt.len()
);
}
}