1use std::collections::BTreeMap;
21use std::path::Path;
22
23#[derive(Debug, Clone)]
29pub struct SystemPrompt {
30 pub cacheable: String,
32 pub dynamic: String,
34}
35
36impl SystemPrompt {
37 pub fn combined(&self) -> String {
39 if self.dynamic.is_empty() {
40 self.cacheable.clone()
41 } else {
42 format!("{}\n\n---\n\n{}", self.cacheable, self.dynamic)
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
52pub struct PromptIdentity {
53 pub tier: String,
55 pub subject: Option<String>,
57}
58
59pub fn build_identity_section(identity: Option<&PromptIdentity>) -> String {
64 match identity {
65 Some(id) => {
66 let subject_line = id
67 .subject
68 .as_deref()
69 .map(|s| format!("\n**Subject**: {s}"))
70 .unwrap_or_default();
71 format!(
72 "## Identity\n\
73 **Agent**: arcan shell\n\
74 **Tier**: {}{}",
75 id.tier, subject_line
76 )
77 }
78 None => "## Identity\n\
79 **Agent**: arcan shell\n\
80 **Tier**: anonymous local agent"
81 .to_string(),
82 }
83}
84
85pub fn build_system_prompt(
90 workspace: &Path,
91 provider_name: &str,
92 model_name: &str,
93 memory_dir: &Path,
94 workspace_context: Option<&str>,
95 skill_catalog: Option<&str>,
96 claude_md_content: Option<&str>,
97) -> SystemPrompt {
98 build_system_prompt_with_identity(
99 workspace,
100 provider_name,
101 model_name,
102 memory_dir,
103 workspace_context,
104 skill_catalog,
105 claude_md_content,
106 None,
107 )
108}
109
110#[allow(clippy::too_many_arguments)]
115pub fn build_system_prompt_with_identity(
116 workspace: &Path,
117 provider_name: &str,
118 model_name: &str,
119 memory_dir: &Path,
120 workspace_context: Option<&str>,
121 skill_catalog: Option<&str>,
122 claude_md_content: Option<&str>,
123 identity: Option<&PromptIdentity>,
124) -> SystemPrompt {
125 let mut cacheable_sections = Vec::new();
127
128 cacheable_sections.push(build_role_section());
130
131 cacheable_sections.push(build_identity_section(identity));
133
134 cacheable_sections.push(build_environment_section(
136 workspace,
137 provider_name,
138 model_name,
139 ));
140
141 if let Some(instructions) = claude_md_content
143 && !instructions.is_empty()
144 {
145 cacheable_sections.push(format!("# Project Instructions\n\n{instructions}"));
146 }
147
148 cacheable_sections.push(build_guidelines_section());
150
151 let cacheable = cacheable_sections.join("\n\n---\n\n");
152
153 let mut dynamic_sections = Vec::new();
155
156 if let Some(git) = build_git_section(workspace) {
158 dynamic_sections.push(git);
159 }
160
161 if let Some(memory) = build_memory_section(memory_dir) {
163 dynamic_sections.push(memory);
164 }
165
166 if let Some(context) = workspace_context
168 && !context.is_empty()
169 {
170 dynamic_sections.push(format!("# Workspace Context\n\n{context}"));
171 }
172
173 if let Some(catalog) = skill_catalog
175 && !catalog.is_empty()
176 {
177 dynamic_sections.push(format!("# Available Skills\n\n{catalog}"));
178 }
179
180 let dynamic = if dynamic_sections.is_empty() {
181 String::new()
182 } else {
183 dynamic_sections.join("\n\n---\n\n")
184 };
185
186 SystemPrompt { cacheable, dynamic }
187}
188
189pub fn build_role_section() -> String {
191 "# System\n\n\
192 You are an AI coding assistant powered by Arcan, the Life Agent OS runtime. \
193 You help users with software engineering tasks by reading files, editing code, \
194 running commands, and searching codebases. Be concise and direct. \
195 Read files before editing them. Use tools to explore rather than guessing. \
196 Follow existing code style and conventions."
197 .to_string()
198}
199
200pub fn build_environment_section(workspace: &Path, provider: &str, model: &str) -> String {
202 let cwd = workspace.display();
203 let platform = std::env::consts::OS;
204 let arch = std::env::consts::ARCH;
205 let date = chrono::Local::now().format("%Y-%m-%d");
206 let shell = std::env::var("SHELL").unwrap_or_else(|_| "unknown".into());
207
208 format!(
209 "# Environment\n\n\
210 - Working directory: {cwd}\n\
211 - Platform: {platform} ({arch})\n\
212 - Shell: {shell}\n\
213 - Date: {date}\n\
214 - Provider: {provider}\n\
215 - Model: {model}"
216 )
217}
218
219pub fn build_git_section(workspace: &Path) -> Option<String> {
223 let branch = std::process::Command::new("git")
224 .args(["rev-parse", "--abbrev-ref", "HEAD"])
225 .current_dir(workspace)
226 .output()
227 .ok()?;
228 if !branch.status.success() {
229 return None;
230 }
231 let branch_name = String::from_utf8_lossy(&branch.stdout).trim().to_string();
232
233 let status = std::process::Command::new("git")
234 .args(["status", "--short"])
235 .current_dir(workspace)
236 .output()
237 .ok()?;
238 let status_text = String::from_utf8_lossy(&status.stdout).trim().to_string();
239 let status_display = if status_text.is_empty() {
240 "Clean".to_string()
241 } else if status_text.len() > 500 {
242 format!("{}...(truncated)", &status_text[..500])
243 } else {
244 status_text
245 };
246
247 let log = std::process::Command::new("git")
248 .args(["log", "--oneline", "-5"])
249 .current_dir(workspace)
250 .output()
251 .ok()?;
252 let log_text = String::from_utf8_lossy(&log.stdout).trim().to_string();
253
254 Some(format!(
255 "# Git Context\n\n\
256 - Branch: {branch_name}\n\
257 - Status:\n```\n{status_display}\n```\n\
258 - Recent commits:\n```\n{log_text}\n```"
259 ))
260}
261
262pub fn load_project_instructions(workspace: &Path) -> Option<String> {
283 let mut contents = Vec::new();
284
285 load_file_if_exists(workspace, "CLAUDE.md", &mut contents);
289
290 load_file_if_exists(workspace, "AGENTS.md", &mut contents);
292
293 load_file_if_exists(workspace, ".claude/CLAUDE.md", &mut contents);
295
296 load_rules_dir(workspace, ".claude/rules", &mut contents);
298
299 if let Some(parent) = workspace.parent() {
303 let parent_claude = parent.join("CLAUDE.md");
304 if parent_claude.exists()
305 && parent_claude != workspace.join("CLAUDE.md")
306 && let Ok(content) = std::fs::read_to_string(&parent_claude)
307 && !content.trim().is_empty()
308 {
309 contents.push(format!(
310 "<!-- from {} -->\n{}",
311 parent_claude.display(),
312 content
313 ));
314 }
315 }
316
317 for doc_file in &["docs/STATUS.md", "docs/ARCHITECTURE.md", "docs/ROADMAP.md"] {
320 let path = workspace.join(doc_file);
321 if path.exists()
322 && let Ok(content) = std::fs::read_to_string(&path)
323 {
324 let trimmed = content.trim();
325 if !trimmed.is_empty() {
326 let truncated = if trimmed.len() > 2000 {
328 format!(
329 "{}\n\n... (truncated, {} total chars — use read_file for full content)",
330 &trimmed[..2000],
331 trimmed.len()
332 )
333 } else {
334 trimmed.to_string()
335 };
336 contents.push(format!("<!-- from {doc_file} -->\n{truncated}"));
337 }
338 }
339 }
340
341 let policy_path = workspace.join(".control/policy.yaml");
345 if policy_path.exists()
346 && let Ok(content) = std::fs::read_to_string(&policy_path)
347 && !content.trim().is_empty()
348 {
349 contents.push(format!(
350 "<!-- Control policy (.control/policy.yaml) -->\n```yaml\n{}\n```",
351 content.trim()
352 ));
353 }
354
355 if contents.is_empty() {
356 None
357 } else {
358 Some(contents.join("\n\n"))
359 }
360}
361
362pub fn load_claude_md(workspace: &Path) -> Option<String> {
364 load_project_instructions(workspace)
365}
366
367fn load_file_if_exists(workspace: &Path, relative: &str, contents: &mut Vec<String>) {
369 let path = workspace.join(relative);
370 if path.exists()
371 && let Ok(content) = std::fs::read_to_string(&path)
372 && !content.trim().is_empty()
373 {
374 contents.push(content);
375 }
376}
377
378fn load_rules_dir(workspace: &Path, relative: &str, contents: &mut Vec<String>) {
380 let rules_dir = workspace.join(relative);
381 if rules_dir.is_dir()
382 && let Ok(entries) = std::fs::read_dir(&rules_dir)
383 {
384 let mut rule_files: Vec<_> = entries
385 .flatten()
386 .filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
387 .collect();
388 rule_files.sort_by_key(std::fs::DirEntry::path);
389
390 for entry in rule_files {
391 if let Ok(content) = std::fs::read_to_string(entry.path())
392 && !content.trim().is_empty()
393 {
394 contents.push(content);
395 }
396 }
397 }
398}
399
400pub fn build_memory_section(memory_dir: &Path) -> Option<String> {
408 if !memory_dir.exists() {
409 return None;
410 }
411
412 let index_path = memory_dir.join("MEMORY.md");
414 if index_path.exists()
415 && let Ok(content) = std::fs::read_to_string(&index_path)
416 && !content.trim().is_empty()
417 {
418 return Some(format!("# Agent Memory\n\n{content}"));
419 }
420
421 let entries = std::fs::read_dir(memory_dir).ok()?;
423 let mut sections = Vec::new();
424
425 for entry in entries.flatten() {
426 let path = entry.path();
427 if path.extension().and_then(|e| e.to_str()) != Some("md") {
428 continue;
429 }
430 if path.file_name().and_then(|n| n.to_str()) == Some("MEMORY.md") {
431 continue;
432 }
433 let key = path
434 .file_stem()
435 .and_then(|s| s.to_str())
436 .unwrap_or("unknown")
437 .to_string();
438 if let Ok(content) = std::fs::read_to_string(&path)
439 && !content.trim().is_empty()
440 {
441 sections.push(format!("## {key}\n{content}"));
442 }
443 }
444
445 if sections.is_empty() {
446 return None;
447 }
448
449 sections.sort();
450 Some(format!(
451 "# Agent Memory (cross-session)\n\n{}",
452 sections.join("\n\n")
453 ))
454}
455
456const MEMORY_INDEX_MAX_LINES: usize = 200;
462
463const MEMORY_INDEX_MAX_BYTES: usize = 25_000;
465
466pub fn generate_memory_index(memory_dir: &Path) -> String {
474 let mut sections: BTreeMap<String, Vec<String>> = BTreeMap::new();
475
476 let Ok(entries) = std::fs::read_dir(memory_dir) else {
477 return String::from("# Memory Index\n");
478 };
479
480 for entry in entries.flatten() {
481 let path = entry.path();
482 if path.extension().and_then(|e| e.to_str()) != Some("md") {
483 continue;
484 }
485 if path.file_name().and_then(|n| n.to_str()) == Some("MEMORY.md") {
486 continue;
487 }
488
489 let key = path
490 .file_stem()
491 .and_then(|s| s.to_str())
492 .unwrap_or("unknown")
493 .to_string();
494 let content = std::fs::read_to_string(&path).unwrap_or_default();
495
496 let mem_type = extract_frontmatter_type(&content).unwrap_or_else(|| "general".to_string());
497
498 let description = extract_first_content_line(&content);
499
500 sections
501 .entry(mem_type)
502 .or_default()
503 .push(format!("- [{}]({}.md) — {}", key, key, description));
504 }
505
506 let mut index = String::from("# Memory Index\n\n");
507 for (section, entries) in §ions {
508 index.push_str(&format!("## {}\n", capitalize(section)));
509 for entry in entries {
510 index.push_str(entry);
511 index.push('\n');
512 }
513 index.push('\n');
514 }
515
516 let lines: Vec<&str> = index.lines().collect();
518 if lines.len() > MEMORY_INDEX_MAX_LINES {
519 index = lines[..MEMORY_INDEX_MAX_LINES].join("\n");
520 index.push_str("\n\n... (truncated, showing first 200 entries)\n");
521 }
522
523 if index.len() > MEMORY_INDEX_MAX_BYTES {
525 index.truncate(MEMORY_INDEX_MAX_BYTES);
526 index.push_str("\n\n... (truncated at 25KB)\n");
527 }
528
529 index
530}
531
532pub fn write_memory_index(memory_dir: &Path) {
536 let _ = std::fs::create_dir_all(memory_dir);
537 let index = generate_memory_index(memory_dir);
538 let index_path = memory_dir.join("MEMORY.md");
539 let _ = std::fs::write(&index_path, &index);
540}
541
542fn extract_frontmatter_type(content: &str) -> Option<String> {
546 if !content.starts_with("---") {
547 return None;
548 }
549 let end = content[3..].find("---")?;
550 let frontmatter = &content[3..3 + end];
551 for line in frontmatter.lines() {
552 let trimmed = line.trim();
553 if let Some(value) = trimmed.strip_prefix("type:") {
554 return Some(value.trim().to_string());
555 }
556 }
557 None
558}
559
560fn extract_first_content_line(content: &str) -> String {
565 let body = if let Some(after_prefix) = content.strip_prefix("---") {
566 after_prefix
567 .find("---")
568 .map(|i| &after_prefix[i + 3..])
569 .unwrap_or(content)
570 } else {
571 content
572 };
573 body.lines()
574 .map(str::trim)
575 .find(|l| !l.is_empty() && !l.starts_with('#'))
576 .unwrap_or("(no description)")
577 .chars()
578 .take(120)
579 .collect()
580}
581
582fn capitalize(s: &str) -> String {
584 let mut c = s.chars();
585 match c.next() {
586 Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
587 None => String::new(),
588 }
589}
590
591pub fn build_bare_prompt(workspace: &Path, provider: &str, model: &str) -> String {
600 let cwd = workspace.display();
601 let platform = std::env::consts::OS;
602 let date = chrono::Local::now().format("%Y-%m-%d");
603
604 format!(
605 "You are an AI coding assistant running on {platform}. \
606 Help with software engineering tasks and answer questions. \
607 Be concise and direct.\n\n\
608 Workspace: {cwd} | Date: {date} | Provider: {provider} | Model: {model}\n\n\
609 You have these capabilities (available as tools when needed):\n\
610 - read_file: Read file contents from the workspace\n\
611 - write_file: Create or overwrite a file\n\
612 - edit_file: Make targeted edits to existing files\n\
613 - bash: Run shell commands\n\
614 - glob: Find files by pattern\n\
615 - grep: Search file contents with regex\n\n\
616 When answering questions directly, respond with plain text. \
617 Only suggest using tools when the user needs to interact with files or run commands."
618 )
619}
620
621pub fn build_guidelines_section() -> String {
623 "# Guidelines\n\n\
624 - Read files before editing them\n\
625 - Use tools to explore the codebase rather than guessing\n\
626 - Be concise and direct in responses\n\
627 - Follow existing code style and conventions\n\
628 - Prefer editing existing files over creating new ones\n\
629 - Do not add features beyond what was asked"
630 .to_string()
631}
632
633pub fn build_peer_context_section(messages: &[String]) -> Option<String> {
638 if messages.is_empty() {
639 return None;
640 }
641 let mut section = String::from("# Peer Activity\n\nRecent messages from other agents:\n\n");
642 for msg in messages.iter().take(10) {
643 section.push_str(&format!("- {msg}\n"));
644 }
645 Some(section)
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use std::fs;
652 use tempfile::TempDir;
653
654 #[test]
655 fn test_build_system_prompt_includes_all_sections() {
656 let tmp = TempDir::new().unwrap();
657 let workspace = tmp.path();
658 let memory_dir = workspace.join(".arcan/memory");
659 fs::create_dir_all(&memory_dir).unwrap();
660 fs::write(memory_dir.join("notes.md"), "Some notes here").unwrap();
661
662 let sp = build_system_prompt(
663 workspace,
664 "anthropic",
665 "claude-sonnet-4-5-20250929",
666 &memory_dir,
667 Some("- Peer session: explored workspace journal"),
668 Some("- skill_a: Does A\n- skill_b: Does B"),
669 Some("# My Project\n\nBuild fast."),
670 );
671 let prompt = sp.combined();
672
673 assert!(prompt.contains("# System"), "missing role section");
675 assert!(
676 prompt.contains("# Environment"),
677 "missing environment section"
678 );
679 assert!(
680 prompt.contains("# Project Instructions"),
681 "missing claude.md section"
682 );
683 assert!(prompt.contains("# Agent Memory"), "missing memory section");
684 assert!(
685 prompt.contains("# Workspace Context"),
686 "missing workspace context section"
687 );
688 assert!(
689 prompt.contains("# Available Skills"),
690 "missing skills section"
691 );
692 assert!(
693 prompt.contains("# Guidelines"),
694 "missing guidelines section"
695 );
696 assert!(prompt.contains("---"), "missing section separators");
698 }
699
700 #[test]
701 fn test_build_system_prompt_omits_empty_sections() {
702 let tmp = TempDir::new().unwrap();
703 let workspace = tmp.path();
704 let memory_dir = workspace.join(".arcan/memory");
705 let sp = build_system_prompt(
708 workspace,
709 "mock",
710 "mock-model",
711 &memory_dir,
712 None,
713 None,
714 None,
715 );
716 let prompt = sp.combined();
717
718 assert!(prompt.contains("# System"));
719 assert!(prompt.contains("# Environment"));
720 assert!(prompt.contains("# Guidelines"));
721 assert!(
722 !prompt.contains("# Project Instructions"),
723 "should omit empty claude.md"
724 );
725 assert!(
726 !prompt.contains("# Agent Memory"),
727 "should omit missing memory"
728 );
729 assert!(
730 !prompt.contains("# Available Skills"),
731 "should omit empty skills"
732 );
733 }
734
735 #[test]
736 fn test_load_claude_md_from_workspace() {
737 let tmp = TempDir::new().unwrap();
738 let workspace = tmp.path();
739 fs::write(workspace.join("CLAUDE.md"), "# Instructions\nDo X.").unwrap();
740
741 let result = load_project_instructions(workspace);
742 assert!(result.is_some());
743 assert!(result.unwrap().contains("Do X."));
744 }
745
746 #[test]
747 fn test_load_agents_md() {
748 let tmp = TempDir::new().unwrap();
749 let workspace = tmp.path();
750 fs::write(workspace.join("AGENTS.md"), "# Agent Rules\nBe safe.").unwrap();
751
752 let result = load_project_instructions(workspace);
753 assert!(result.is_some());
754 assert!(result.unwrap().contains("Be safe."));
755 }
756
757 #[test]
758 fn test_load_both_claude_and_agents_md() {
759 let tmp = TempDir::new().unwrap();
760 let workspace = tmp.path();
761 fs::write(workspace.join("CLAUDE.md"), "Claude rules.").unwrap();
762 fs::write(workspace.join("AGENTS.md"), "Agent rules.").unwrap();
763
764 let result = load_project_instructions(workspace).unwrap();
765 assert!(result.contains("Claude rules."));
766 assert!(result.contains("Agent rules."));
767 }
768
769 #[test]
770 fn test_load_rules_dir() {
771 let tmp = TempDir::new().unwrap();
772 let workspace = tmp.path();
773 let rules_dir = workspace.join(".claude/rules");
774 fs::create_dir_all(&rules_dir).unwrap();
775 fs::write(rules_dir.join("code-style.md"), "Use snake_case.").unwrap();
776 fs::write(rules_dir.join("testing.md"), "All code needs tests.").unwrap();
777
778 let result = load_project_instructions(workspace);
779 assert!(result.is_some());
780 let content = result.unwrap();
781 assert!(content.contains("Use snake_case."));
782 assert!(content.contains("All code needs tests."));
783 }
784
785 #[test]
786 fn test_load_docs_context() {
787 let tmp = TempDir::new().unwrap();
788 let workspace = tmp.path();
789 let docs_dir = workspace.join("docs");
790 fs::create_dir_all(&docs_dir).unwrap();
791 fs::write(docs_dir.join("STATUS.md"), "# Status\n100% tests passing").unwrap();
792 fs::write(docs_dir.join("ARCHITECTURE.md"), "# Arch\nEvent-sourced.").unwrap();
793
794 let result = load_project_instructions(workspace).unwrap();
795 assert!(result.contains("100% tests passing"));
796 assert!(result.contains("Event-sourced."));
797 }
798
799 #[test]
800 fn test_load_control_policy() {
801 let tmp = TempDir::new().unwrap();
802 let workspace = tmp.path();
803 let control_dir = workspace.join(".control");
804 fs::create_dir_all(&control_dir).unwrap();
805 fs::write(
806 control_dir.join("policy.yaml"),
807 "gates:\n - name: G1\n blocking: true",
808 )
809 .unwrap();
810
811 let result = load_project_instructions(workspace).unwrap();
812 assert!(result.contains("gates:"));
813 assert!(result.contains("blocking: true"));
814 }
815
816 #[test]
817 fn test_load_empty_workspace_returns_none() {
818 let tmp = TempDir::new().unwrap();
819 let result = load_project_instructions(tmp.path());
820 assert!(result.is_none());
821 }
822
823 #[test]
824 fn test_git_section_in_repo() {
825 let workspace = std::env::current_dir().unwrap();
827 let result = build_git_section(&workspace);
828 if let Some(git_section) = result {
831 assert!(git_section.contains("# Git Context"));
832 assert!(git_section.contains("Branch:"));
833 }
834 }
836
837 #[test]
838 fn test_git_section_non_repo() {
839 let tmp = TempDir::new().unwrap();
840 let result = build_git_section(tmp.path());
841 assert!(result.is_none(), "non-repo dir should return None");
842 }
843
844 #[test]
845 fn test_environment_section() {
846 let tmp = TempDir::new().unwrap();
847 let section = build_environment_section(tmp.path(), "anthropic", "claude-sonnet");
848
849 assert!(section.contains("# Environment"));
850 assert!(section.contains("Platform:"));
851 assert!(section.contains("Provider: anthropic"));
852 assert!(section.contains("Model: claude-sonnet"));
853 assert!(section.contains("Date:"));
854 }
855
856 #[test]
857 fn test_memory_section() {
858 let tmp = TempDir::new().unwrap();
859 let memory_dir = tmp.path().join("memory");
860 fs::create_dir_all(&memory_dir).unwrap();
861 fs::write(memory_dir.join("notes.md"), "Remember this.").unwrap();
862
863 let result = build_memory_section(&memory_dir);
864 assert!(result.is_some());
865 let content = result.unwrap();
866 assert!(content.contains("# Agent Memory"));
867 assert!(content.contains("Remember this."));
868 }
869
870 #[test]
871 fn test_memory_section_empty_dir() {
872 let tmp = TempDir::new().unwrap();
873 let memory_dir = tmp.path().join("memory");
874 fs::create_dir_all(&memory_dir).unwrap();
875
876 let result = build_memory_section(&memory_dir);
877 assert!(result.is_none(), "empty memory dir should return None");
878 }
879
880 #[test]
881 fn test_memory_section_missing_dir() {
882 let tmp = TempDir::new().unwrap();
883 let memory_dir = tmp.path().join("nonexistent");
884
885 let result = build_memory_section(&memory_dir);
886 assert!(result.is_none(), "missing memory dir should return None");
887 }
888
889 #[test]
890 fn test_role_section_content() {
891 let role = build_role_section();
892 assert!(role.contains("Arcan"));
893 assert!(role.contains("Life Agent OS"));
894 }
895
896 #[test]
897 fn test_guidelines_section_content() {
898 let guidelines = build_guidelines_section();
899 assert!(guidelines.contains("Read files before editing"));
900 assert!(guidelines.contains("Do not add features beyond what was asked"));
901 }
902
903 #[test]
904 fn test_load_combines_all_sources() {
905 let tmp = TempDir::new().unwrap();
906 let workspace = tmp.path();
907
908 fs::write(workspace.join("CLAUDE.md"), "Root instructions.").unwrap();
910 fs::write(workspace.join("AGENTS.md"), "Agent boundaries.").unwrap();
911 let dot_claude = workspace.join(".claude");
912 fs::create_dir_all(&dot_claude).unwrap();
913 fs::write(dot_claude.join("CLAUDE.md"), "Dot-claude instructions.").unwrap();
914 let rules_dir = dot_claude.join("rules");
915 fs::create_dir_all(&rules_dir).unwrap();
916 fs::write(rules_dir.join("style.md"), "Style rules.").unwrap();
917 let docs = workspace.join("docs");
918 fs::create_dir_all(&docs).unwrap();
919 fs::write(docs.join("STATUS.md"), "All green.").unwrap();
920 let control = workspace.join(".control");
921 fs::create_dir_all(&control).unwrap();
922 fs::write(control.join("policy.yaml"), "version: 1").unwrap();
923
924 let result = load_project_instructions(workspace).unwrap();
925 assert!(result.contains("Root instructions."));
926 assert!(result.contains("Agent boundaries."));
927 assert!(result.contains("Dot-claude instructions."));
928 assert!(result.contains("Style rules."));
929 assert!(result.contains("All green."));
930 assert!(result.contains("version: 1"));
931 }
932
933 #[test]
934 fn test_backward_compat_load_claude_md() {
935 let tmp = TempDir::new().unwrap();
936 let workspace = tmp.path();
937 fs::write(workspace.join("CLAUDE.md"), "Legacy call.").unwrap();
938 let result = load_claude_md(workspace);
939 assert!(result.is_some());
940 assert!(result.unwrap().contains("Legacy call."));
941 }
942
943 #[test]
945 fn test_prompt_available_from_core() {
946 let _ = build_system_prompt
948 as fn(
949 &Path,
950 &str,
951 &str,
952 &Path,
953 Option<&str>,
954 Option<&str>,
955 Option<&str>,
956 ) -> SystemPrompt;
957 let _ = build_system_prompt_with_identity
958 as fn(
959 &Path,
960 &str,
961 &str,
962 &Path,
963 Option<&str>,
964 Option<&str>,
965 Option<&str>,
966 Option<&PromptIdentity>,
967 ) -> SystemPrompt;
968 let _ = build_identity_section as fn(Option<&PromptIdentity>) -> String;
969 let _ = build_git_section as fn(&Path) -> Option<String>;
970 let _ = load_project_instructions as fn(&Path) -> Option<String>;
971 let _ = build_environment_section as fn(&Path, &str, &str) -> String;
972 let _ = build_memory_section as fn(&Path) -> Option<String>;
973 let _ = build_role_section as fn() -> String;
974 let _ = build_guidelines_section as fn() -> String;
975 let _ = build_bare_prompt as fn(&Path, &str, &str) -> String;
976 let _ = generate_memory_index as fn(&Path) -> String;
977 let _ = write_memory_index as fn(&Path);
978 }
979
980 #[test]
983 fn test_identity_section_with_full_identity() {
984 let id = PromptIdentity {
985 tier: "pro".to_string(),
986 subject: Some("user@example.com".to_string()),
987 };
988 let section = build_identity_section(Some(&id));
989 assert!(section.contains("## Identity"));
990 assert!(section.contains("**Agent**: arcan shell"));
991 assert!(section.contains("**Tier**: pro"));
992 assert!(section.contains("**Subject**: user@example.com"));
993 }
994
995 #[test]
996 fn test_identity_section_without_subject() {
997 let id = PromptIdentity {
998 tier: "free".to_string(),
999 subject: None,
1000 };
1001 let section = build_identity_section(Some(&id));
1002 assert!(section.contains("**Tier**: free"));
1003 assert!(!section.contains("**Subject**"));
1004 }
1005
1006 #[test]
1007 fn test_identity_section_anonymous() {
1008 let section = build_identity_section(None);
1009 assert!(section.contains("## Identity"));
1010 assert!(section.contains("anonymous local agent"));
1011 }
1012
1013 #[test]
1014 fn test_system_prompt_with_identity_includes_block() {
1015 let tmp = TempDir::new().unwrap();
1016 let workspace = tmp.path();
1017 let memory_dir = workspace.join(".arcan/memory");
1018
1019 let id = PromptIdentity {
1020 tier: "enterprise".to_string(),
1021 subject: Some("admin@corp.com".to_string()),
1022 };
1023 let sp = build_system_prompt_with_identity(
1024 workspace,
1025 "anthropic",
1026 "claude-sonnet",
1027 &memory_dir,
1028 None,
1029 None,
1030 None,
1031 Some(&id),
1032 );
1033 let combined = sp.combined();
1034 assert!(combined.contains("## Identity"), "missing identity section");
1035 assert!(
1036 combined.contains("**Tier**: enterprise"),
1037 "missing tier in identity"
1038 );
1039 assert!(
1040 combined.contains("**Subject**: admin@corp.com"),
1041 "missing subject in identity"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_system_prompt_without_identity_shows_anonymous() {
1047 let tmp = TempDir::new().unwrap();
1048 let workspace = tmp.path();
1049 let memory_dir = workspace.join(".arcan/memory");
1050
1051 let sp = build_system_prompt(workspace, "mock", "mock", &memory_dir, None, None, None);
1052 let combined = sp.combined();
1053 assert!(
1054 combined.contains("anonymous local agent"),
1055 "should show anonymous when no identity"
1056 );
1057 }
1058
1059 #[test]
1062 fn test_generate_memory_index() {
1063 let tmp = TempDir::new().unwrap();
1064 let memory_dir = tmp.path().join("memory");
1065 fs::create_dir_all(&memory_dir).unwrap();
1066
1067 fs::write(
1068 memory_dir.join("project_notes.md"),
1069 "Key architecture decisions for the project.",
1070 )
1071 .unwrap();
1072 fs::write(
1073 memory_dir.join("user_prefs.md"),
1074 "---\ntype: user\n---\n# Preferences\nPrefers dark mode.",
1075 )
1076 .unwrap();
1077
1078 let index = generate_memory_index(&memory_dir);
1079
1080 assert!(index.contains("# Memory Index"), "missing header");
1081 assert!(
1082 index.contains("[project_notes]"),
1083 "missing project_notes entry"
1084 );
1085 assert!(index.contains("[user_prefs]"), "missing user_prefs entry");
1086 assert!(index.contains("## User"), "missing User section header");
1088 assert!(
1090 index.contains("## General"),
1091 "missing General section header"
1092 );
1093 assert!(
1095 index.contains("Key architecture decisions"),
1096 "missing description from project_notes"
1097 );
1098 assert!(
1099 index.contains("Prefers dark mode"),
1100 "missing description from user_prefs"
1101 );
1102 }
1103
1104 #[test]
1105 fn test_memory_index_skips_memory_md() {
1106 let tmp = TempDir::new().unwrap();
1107 let memory_dir = tmp.path().join("memory");
1108 fs::create_dir_all(&memory_dir).unwrap();
1109
1110 fs::write(memory_dir.join("MEMORY.md"), "# Old index").unwrap();
1111 fs::write(memory_dir.join("real_note.md"), "A real note.").unwrap();
1112
1113 let index = generate_memory_index(&memory_dir);
1114 assert!(index.contains("[real_note]"));
1115 assert!(!index.contains("[MEMORY]"));
1117 }
1118
1119 #[test]
1120 fn test_memory_index_caps_at_200_lines() {
1121 let tmp = TempDir::new().unwrap();
1122 let memory_dir = tmp.path().join("memory");
1123 fs::create_dir_all(&memory_dir).unwrap();
1124
1125 for i in 0..250 {
1129 fs::write(
1130 memory_dir.join(format!("note_{i:03}.md")),
1131 format!("Content for note {i}."),
1132 )
1133 .unwrap();
1134 }
1135
1136 let index = generate_memory_index(&memory_dir);
1137 let line_count = index.lines().count();
1138 assert!(line_count <= 205, "expected <= 205 lines, got {line_count}");
1140 assert!(
1141 index.contains("truncated"),
1142 "should contain truncation notice"
1143 );
1144 }
1145
1146 #[test]
1147 fn test_memory_index_extracts_frontmatter_type() {
1148 let tmp = TempDir::new().unwrap();
1149 let memory_dir = tmp.path().join("memory");
1150 fs::create_dir_all(&memory_dir).unwrap();
1151
1152 fs::write(
1153 memory_dir.join("arch_notes.md"),
1154 "---\ntype: project\ntags: [arch]\n---\n# Architecture\nEvent-sourced design.",
1155 )
1156 .unwrap();
1157 fs::write(
1158 memory_dir.join("tax_info.md"),
1159 "---\ntype: user\n---\nColombian tax rules.",
1160 )
1161 .unwrap();
1162 fs::write(
1163 memory_dir.join("general_stuff.md"),
1164 "Just some general notes without frontmatter.",
1165 )
1166 .unwrap();
1167
1168 let index = generate_memory_index(&memory_dir);
1169
1170 assert!(index.contains("## Project"), "missing Project section");
1171 assert!(index.contains("## User"), "missing User section");
1172 assert!(index.contains("## General"), "missing General section");
1173 }
1174
1175 #[test]
1176 fn test_write_memory_index_creates_file() {
1177 let tmp = TempDir::new().unwrap();
1178 let memory_dir = tmp.path().join("memory");
1179 fs::create_dir_all(&memory_dir).unwrap();
1180 fs::write(memory_dir.join("test.md"), "Test content.").unwrap();
1181
1182 write_memory_index(&memory_dir);
1183
1184 let index_path = memory_dir.join("MEMORY.md");
1185 assert!(index_path.exists(), "MEMORY.md should be created");
1186 let content = fs::read_to_string(&index_path).unwrap();
1187 assert!(content.contains("# Memory Index"));
1188 assert!(content.contains("[test]"));
1189 }
1190
1191 #[test]
1192 fn test_memory_section_prefers_index() {
1193 let tmp = TempDir::new().unwrap();
1194 let memory_dir = tmp.path().join("memory");
1195 fs::create_dir_all(&memory_dir).unwrap();
1196
1197 fs::write(memory_dir.join("notes.md"), "Individual note.").unwrap();
1198 write_memory_index(&memory_dir);
1200
1201 let section = build_memory_section(&memory_dir).unwrap();
1202 assert!(
1204 section.contains("Memory Index"),
1205 "should prefer MEMORY.md index"
1206 );
1207 }
1208
1209 #[test]
1212 fn test_system_prompt_struct_has_both_sections() {
1213 let tmp = TempDir::new().unwrap();
1214 let workspace = tmp.path();
1215 let memory_dir = workspace.join(".arcan/memory");
1216 fs::create_dir_all(&memory_dir).unwrap();
1217 fs::write(memory_dir.join("notes.md"), "Some notes.").unwrap();
1218
1219 let sp = build_system_prompt(
1220 workspace,
1221 "anthropic",
1222 "claude-sonnet",
1223 &memory_dir,
1224 None,
1225 Some("- skill_a: Does A"),
1226 Some("Build fast."),
1227 );
1228
1229 assert!(!sp.cacheable.is_empty(), "cacheable should not be empty");
1230 assert!(!sp.dynamic.is_empty(), "dynamic should not be empty");
1231 }
1232
1233 #[test]
1234 fn test_cacheable_section_stable() {
1235 let tmp = TempDir::new().unwrap();
1236 let workspace = tmp.path();
1237 fs::write(workspace.join("CLAUDE.md"), "Project rules.").unwrap();
1238 let memory_dir = workspace.join(".arcan/memory");
1239
1240 let sp1 = build_system_prompt(
1241 workspace,
1242 "anthropic",
1243 "claude-sonnet",
1244 &memory_dir,
1245 None,
1246 None,
1247 Some("Project rules."),
1248 );
1249 let sp2 = build_system_prompt(
1250 workspace,
1251 "anthropic",
1252 "claude-sonnet",
1253 &memory_dir,
1254 None,
1255 None,
1256 Some("Project rules."),
1257 );
1258
1259 assert_eq!(
1260 sp1.cacheable, sp2.cacheable,
1261 "cacheable section should be identical for same inputs"
1262 );
1263 }
1264
1265 #[test]
1266 fn test_dynamic_section_changes_with_memory() {
1267 let tmp = TempDir::new().unwrap();
1268 let workspace = tmp.path();
1269 let memory_dir = workspace.join(".arcan/memory");
1270 fs::create_dir_all(&memory_dir).unwrap();
1271
1272 let sp1 = build_system_prompt(
1274 workspace,
1275 "anthropic",
1276 "claude-sonnet",
1277 &memory_dir,
1278 None,
1279 None,
1280 None,
1281 );
1282
1283 fs::write(memory_dir.join("new_note.md"), "New insight.").unwrap();
1285
1286 let sp2 = build_system_prompt(
1287 workspace,
1288 "anthropic",
1289 "claude-sonnet",
1290 &memory_dir,
1291 None,
1292 None,
1293 None,
1294 );
1295
1296 assert_ne!(
1297 sp1.dynamic, sp2.dynamic,
1298 "dynamic section should change when memory files are added"
1299 );
1300 assert_eq!(
1302 sp1.cacheable, sp2.cacheable,
1303 "cacheable section should not change with memory"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_cacheable_contains_role_env_guidelines() {
1309 let tmp = TempDir::new().unwrap();
1310 let workspace = tmp.path();
1311 let memory_dir = workspace.join("memory");
1312
1313 let sp = build_system_prompt(
1314 workspace,
1315 "anthropic",
1316 "claude-sonnet",
1317 &memory_dir,
1318 None,
1319 None,
1320 None,
1321 );
1322
1323 assert!(
1324 sp.cacheable.contains("# System"),
1325 "cacheable should contain role"
1326 );
1327 assert!(
1328 sp.cacheable.contains("# Environment"),
1329 "cacheable should contain environment"
1330 );
1331 assert!(
1332 sp.cacheable.contains("# Guidelines"),
1333 "cacheable should contain guidelines"
1334 );
1335 }
1336
1337 #[test]
1338 fn test_dynamic_contains_git_memory_skills() {
1339 let tmp = TempDir::new().unwrap();
1340 let workspace = tmp.path();
1341 let memory_dir = workspace.join(".arcan/memory");
1342 fs::create_dir_all(&memory_dir).unwrap();
1343 fs::write(memory_dir.join("notes.md"), "Remember.").unwrap();
1344
1345 let sp = build_system_prompt(
1346 workspace,
1347 "anthropic",
1348 "claude-sonnet",
1349 &memory_dir,
1350 Some("- Session abc turn 3: Added memory_similar"),
1351 Some("- skill_a"),
1352 None,
1353 );
1354
1355 assert!(
1356 sp.dynamic.contains("# Agent Memory"),
1357 "dynamic should contain memory"
1358 );
1359 assert!(
1360 sp.dynamic.contains("# Workspace Context"),
1361 "dynamic should contain workspace context"
1362 );
1363 assert!(
1364 sp.dynamic.contains("# Available Skills"),
1365 "dynamic should contain skills"
1366 );
1367 }
1368
1369 #[test]
1370 fn test_backward_compat_combined() {
1371 let tmp = TempDir::new().unwrap();
1372 let workspace = tmp.path();
1373 let memory_dir = workspace.join(".arcan/memory");
1374 fs::create_dir_all(&memory_dir).unwrap();
1375 fs::write(memory_dir.join("notes.md"), "Some notes.").unwrap();
1376
1377 let sp = build_system_prompt(
1378 workspace,
1379 "anthropic",
1380 "claude-sonnet",
1381 &memory_dir,
1382 None,
1383 Some("- skill_a"),
1384 Some("Project instructions."),
1385 );
1386 let combined = sp.combined();
1387
1388 assert!(combined.contains("# System"));
1390 assert!(combined.contains("# Guidelines"));
1391 assert!(combined.contains("# Agent Memory"));
1392 assert!(combined.contains("# Available Skills"));
1393 }
1394
1395 #[test]
1396 fn test_combined_empty_dynamic() {
1397 let tmp = TempDir::new().unwrap();
1398 let workspace = tmp.path();
1399 let memory_dir = workspace.join("nonexistent");
1400
1401 let sp = build_system_prompt(workspace, "mock", "mock", &memory_dir, None, None, None);
1402
1403 let combined = sp.combined();
1405 assert_eq!(combined, sp.cacheable);
1407 }
1408
1409 #[test]
1412 fn test_extract_frontmatter_type_valid() {
1413 let content = "---\ntype: project\ntags: [a, b]\n---\n# Title\nBody.";
1414 assert_eq!(
1415 extract_frontmatter_type(content),
1416 Some("project".to_string())
1417 );
1418 }
1419
1420 #[test]
1421 fn test_extract_frontmatter_type_missing() {
1422 let content = "---\ntags: [a]\n---\nNo type field.";
1423 assert_eq!(extract_frontmatter_type(content), None);
1424 }
1425
1426 #[test]
1427 fn test_extract_frontmatter_type_no_frontmatter() {
1428 let content = "Just plain text.";
1429 assert_eq!(extract_frontmatter_type(content), None);
1430 }
1431
1432 #[test]
1433 fn test_extract_first_content_line_with_frontmatter() {
1434 let content = "---\ntype: user\n---\n# Heading\nFirst real line.";
1435 assert_eq!(extract_first_content_line(content), "First real line.");
1436 }
1437
1438 #[test]
1439 fn test_extract_first_content_line_no_frontmatter() {
1440 let content = "# Heading\nContent line.";
1441 assert_eq!(extract_first_content_line(content), "Content line.");
1442 }
1443
1444 #[test]
1445 fn test_extract_first_content_line_empty() {
1446 let content = "";
1447 assert_eq!(extract_first_content_line(content), "(no description)");
1448 }
1449
1450 #[test]
1451 fn test_capitalize() {
1452 assert_eq!(capitalize("general"), "General");
1453 assert_eq!(capitalize("user"), "User");
1454 assert_eq!(capitalize(""), "");
1455 assert_eq!(capitalize("ALREADY"), "ALREADY");
1456 }
1457
1458 #[test]
1459 fn test_bare_prompt_is_compact() {
1460 let tmp = TempDir::new().unwrap();
1461 let prompt = build_bare_prompt(tmp.path(), "apfel", "apple-foundationmodel");
1462
1463 assert!(prompt.contains("AI coding assistant"), "missing role");
1465 assert!(prompt.contains("Date:"), "missing date");
1466 assert!(prompt.contains("Provider: apfel"), "missing provider");
1467 assert!(
1468 prompt.contains("Model: apple-foundationmodel"),
1469 "missing model"
1470 );
1471
1472 assert!(prompt.contains("read_file"), "missing read_file tool");
1474 assert!(prompt.contains("bash"), "missing bash tool");
1475 assert!(prompt.contains("grep"), "missing grep tool");
1476
1477 assert!(
1479 !prompt.contains("# Project Instructions"),
1480 "bare prompt should not have project instructions"
1481 );
1482 assert!(
1483 !prompt.contains("# Agent Memory"),
1484 "bare prompt should not have memory"
1485 );
1486 assert!(
1487 !prompt.contains("# Git Context"),
1488 "bare prompt should not have git context"
1489 );
1490
1491 assert!(
1493 prompt.len() < 1200,
1494 "bare prompt too long: {} chars (target <1200)",
1495 prompt.len()
1496 );
1497 }
1498}