use std::path::Path;
use anyhow::{Context, Result};
use chrono::Utc;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::shortstring::ShortString;
pub const EVOLUTION_LOG_CAP: usize = 10;
pub const LEGACY_REQUIRED_SECTIONS: &[&str] =
&["Identity", "Values", "Interests", "Voice"];
#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug)]
pub struct Soul {
pub name: ShortString<64>,
pub identity: ShortString<1024>,
pub values: Vec<ShortString<512>>,
pub interests: Interests,
pub voice: ShortString<1024>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub boundaries: Option<ShortString<1024>>,
#[serde(default)]
pub evolution_log: Vec<EvolutionEntry>,
}
#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug)]
pub struct Interests {
pub communities: Vec<ShortString<64>>,
#[serde(default)]
pub topics: Vec<ShortString<512>>,
}
#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug, PartialEq, Eq)]
pub struct EvolutionEntry {
pub date: chrono::NaiveDate,
pub note: ShortString<512>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SoulWarning {
pub level: WarnLevel,
pub field: String,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum WarnLevel {
Error,
Warning,
Info,
}
impl std::fmt::Display for SoulWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let level = match self.level {
WarnLevel::Error => "ERROR",
WarnLevel::Warning => "WARN",
WarnLevel::Info => "INFO",
};
write!(f, "[{level}] {}: {}", self.field, self.message)
}
}
impl Soul {
pub async fn from_file(path: &Path) -> Result<Self> {
let bytes = tokio::fs::read(path).await.with_context(|| {
format!("reading SOUL.json from {}", path.display())
})?;
serde_json::from_slice(&bytes)
.map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))
}
pub async fn save(&self, path: &Path) -> Result<()> {
let warnings = self.validate();
let errors: Vec<_> = warnings
.iter()
.filter(|w| w.level == WarnLevel::Error)
.collect();
if !errors.is_empty() {
anyhow::bail!(
"refusing to save invalid Soul: {}",
errors
.iter()
.map(|w| w.to_string())
.collect::<Vec<_>>()
.join("; ")
);
}
if path.exists() {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let backup = path.with_file_name(format!("SOUL.{ts}.json"));
if let Err(e) = tokio::fs::rename(path, &backup).await {
tracing::warn!("Failed to backup {}: {e}", path.display());
}
}
let json = serde_json::to_vec_pretty(self)?;
tokio::fs::write(path, &json).await.with_context(|| {
format!("writing SOUL.json to {}", path.display())
})?;
Ok(())
}
pub fn validate(&self) -> Vec<SoulWarning> {
let mut out = Vec::new();
if self.interests.communities.is_empty() {
out.push(SoulWarning {
level: WarnLevel::Error,
field: "interests.communities".to_string(),
message: "no communities - agent will use global feed"
.to_string(),
});
}
if self.values.is_empty() {
out.push(SoulWarning {
level: WarnLevel::Warning,
field: "values".to_string(),
message: "no values listed".to_string(),
});
}
out
}
pub fn validate_communities(&self, known: &[String]) -> Vec<SoulWarning> {
self.interests
.communities
.iter()
.filter(|slug| !known.iter().any(|k| k == slug.as_str()))
.map(|slug| SoulWarning {
level: WarnLevel::Warning,
field: "interests.communities".to_string(),
message: format!("unknown community slug {:?}", slug.as_str()),
})
.collect()
}
pub fn communities(&self) -> Vec<String> {
self.interests
.communities
.iter()
.map(|c| c.to_string())
.collect()
}
pub fn push_evolution(&mut self, note: impl Into<String>) -> Result<()> {
let note = ShortString::<512>::new(note.into())
.map_err(|e| anyhow::anyhow!("evolution note: {e}"))?;
self.evolution_log.push(EvolutionEntry {
date: Utc::now().date_naive(),
note,
});
if self.evolution_log.len() > EVOLUTION_LOG_CAP {
let drop = self.evolution_log.len() - EVOLUTION_LOG_CAP;
self.evolution_log.drain(0..drop);
}
Ok(())
}
pub fn append_evolution(&mut self, entry: &str) {
let note = strip_date_prefix(entry);
if let Err(e) = self.push_evolution(note) {
tracing::warn!("dropping evolution entry: {e}");
}
}
pub fn section(&self, name: &str) -> Option<String> {
match name {
"Identity" => Some(self.identity.to_string()),
"Values" => Some(
self.values
.iter()
.map(|v| format!("- {v}"))
.collect::<Vec<_>>()
.join("\n"),
),
"Voice" => Some(self.voice.to_string()),
"Boundaries" => self.boundaries.as_ref().map(|b| b.to_string()),
"Interests" => Some(self.render_interests()),
"Evolution Log" => Some(
self.evolution_log
.iter()
.map(|e| format!("- {}: {}", e.date, e.note))
.collect::<Vec<_>>()
.join("\n"),
),
_ => None,
}
}
pub fn markdown(&self) -> String {
self.render_inner()
}
pub fn render(&self) -> String {
self.render_inner()
}
fn render_inner(&self) -> String {
let mut out = format!("# {}\n\n", self.name);
out.push_str("## Identity\n\n");
out.push_str(&self.identity);
out.push_str("\n\n## Values\n\n");
for v in &self.values {
out.push_str(&format!("- {v}\n"));
}
out.push_str("\n## Interests\n\n");
out.push_str(&self.render_interests());
out.push_str("\n\n## Voice\n\n");
out.push_str(&self.voice);
out.push('\n');
if let Some(b) = &self.boundaries {
out.push_str("\n## Boundaries\n\n");
out.push_str(b);
out.push('\n');
}
if !self.evolution_log.is_empty() {
out.push_str("\n## Evolution Log\n\n");
for e in &self.evolution_log {
out.push_str(&format!("- {}: {}\n", e.date, e.note));
}
}
out
}
fn render_interests(&self) -> String {
let mut out = String::new();
if !self.interests.communities.is_empty() {
out.push_str("### Communities\n\n");
for c in &self.interests.communities {
out.push_str(&format!("- {c}\n"));
}
}
if !self.interests.topics.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str("### Topics\n\n");
for t in &self.interests.topics {
out.push_str(&format!("- {t}\n"));
}
}
out.trim_end().to_string()
}
pub fn as_system_prompt(&self) -> String {
self.render()
}
pub fn full_schema() -> serde_json::Value {
let full = schemars::schema_for!(Soul);
serde_json::to_value(&full).expect("Soul schema should serialize")
}
pub async fn from_legacy_markdown_file(path: &Path) -> Result<Self> {
let content =
tokio::fs::read_to_string(path).await.with_context(|| {
format!("reading legacy SOUL.md from {}", path.display())
})?;
Self::parse_legacy_markdown(&content)
}
pub fn parse_legacy_markdown(content: &str) -> Result<Self> {
let (name, sections) = split_legacy(content)?;
let identity = sections
.iter()
.find(|(n, _)| n == "Identity")
.map(|(_, c)| c.clone())
.unwrap_or_default();
let values: Vec<String> = sections
.iter()
.find(|(n, _)| n == "Values")
.map(|(_, c)| extract_bullets(c))
.unwrap_or_default();
let voice = sections
.iter()
.find(|(n, _)| n == "Voice")
.map(|(_, c)| c.clone())
.unwrap_or_default();
let boundaries = sections
.iter()
.find(|(n, _)| n == "Boundaries")
.map(|(_, c)| c.clone())
.filter(|s| !s.is_empty());
let interests_block = sections
.iter()
.find(|(n, _)| n == "Interests")
.map(|(_, c)| c.clone())
.unwrap_or_default();
let interests = parse_legacy_interests(&interests_block);
let evolution_log = sections
.iter()
.find(|(n, _)| n == "Evolution Log")
.map(|(_, c)| parse_legacy_evolution(c))
.unwrap_or_default();
let json = serde_json::json!({
"name": name,
"identity": identity,
"values": values,
"interests": {
"communities": interests.0,
"topics": interests.1,
},
"voice": voice,
"boundaries": boundaries,
"evolution_log": evolution_log,
});
let json_str = serde_json::to_string(&json)?;
let soul: Soul = serde_json::from_str(&json_str).map_err(|e| {
anyhow::anyhow!("legacy markdown failed schema: {e}")
})?;
Ok(soul)
}
pub fn parse(content: &str) -> Result<Self> {
Self::parse_legacy_markdown(content)
}
}
fn split_legacy(content: &str) -> Result<(String, Vec<(String, String)>)> {
let mut name = String::new();
let mut sections: Vec<(String, String)> = Vec::new();
let mut current_section: Option<String> = None;
let mut current_content = String::new();
for line in content.lines() {
if let Some(heading) = line.strip_prefix("# ") {
if name.is_empty() {
name = heading.trim().to_string();
}
} else if let Some(heading) = line.strip_prefix("## ") {
if let Some(section_name) = current_section.take() {
sections
.push((section_name, current_content.trim().to_string()));
}
current_section = Some(heading.trim().to_string());
current_content = String::new();
} else if current_section.is_some() {
current_content.push_str(line);
current_content.push('\n');
}
}
if let Some(section_name) = current_section {
sections.push((section_name, current_content.trim().to_string()));
}
if name.is_empty() {
anyhow::bail!("legacy SOUL.md must have a top-level heading");
}
Ok((name, sections))
}
fn extract_bullets(content: &str) -> Vec<String> {
let mut out = Vec::new();
for line in content.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
{
let v = rest.trim();
if !v.is_empty() {
out.push(v.to_string());
}
}
}
out
}
fn parse_legacy_interests(content: &str) -> (Vec<String>, Vec<String>) {
let mut communities: Vec<String> = Vec::new();
let mut topics: Vec<String> = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let body = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.unwrap_or(trimmed);
let body = body
.strip_prefix("**")
.and_then(|s| s.strip_suffix("**"))
.unwrap_or(body);
let lower = body.to_lowercase();
if let Some(slug) = lower
.strip_prefix("community: ")
.or_else(|| lower.strip_prefix("community:"))
{
let slug = slug.split('(').next().unwrap_or(slug).trim();
let slug = slug
.split(" —")
.next()
.unwrap_or(slug)
.split(" -")
.next()
.unwrap_or(slug)
.trim();
let slug = slug
.replace(['\u{2011}', '\u{2010}', '\u{2013}', '\u{2012}'], "-");
let slug = slug.trim_end_matches(['.', ',', ';', ':']);
let canonical = match slug {
"technology" => "tech",
"meta/governance" => "meta-governance",
other => other,
};
if !communities.iter().any(|c| c == canonical) {
communities.push(canonical.to_string());
}
} else if !body.is_empty() {
topics.push(body.to_string());
}
}
(communities, topics)
}
fn parse_legacy_evolution(content: &str) -> Vec<EvolutionEntry> {
let mut out = Vec::new();
for line in content.lines() {
let trimmed = line.trim_start();
let body = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.unwrap_or(trimmed)
.trim();
if body.is_empty() {
continue;
}
if let Some((date_str, note)) = body.split_once(": ")
&& let Ok(date) =
chrono::NaiveDate::parse_from_str(date_str.trim(), "%Y-%m-%d")
&& let Ok(note) = ShortString::<512>::new(note.trim())
{
out.push(EvolutionEntry { date, note });
continue;
}
let note_text = if body.chars().count() > 512 {
body.chars().take(512).collect::<String>()
} else {
body.to_string()
};
if let Ok(note) = ShortString::<512>::new(note_text) {
out.push(EvolutionEntry {
date: Utc::now().date_naive(),
note,
});
}
}
out
}
fn strip_date_prefix(s: &str) -> String {
if s.len() >= 11
&& s.as_bytes().get(10) == Some(&b':')
&& let Ok(_d) = chrono::NaiveDate::parse_from_str(&s[..10], "%Y-%m-%d")
{
return s[11..].trim_start().to_string();
}
s.to_string()
}
#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug)]
pub struct Feedback {
pub text: ShortString<2048>,
pub contact_me: bool,
}
#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug)]
pub struct EvolutionRequest {
pub note: ShortString<512>,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Soul {
let json = serde_json::json!({
"name": "ada",
"identity": "Methodical engineer.",
"values": ["Clarity", "Evidence"],
"interests": {
"communities": ["tech", "philosophy"],
"topics": ["systems design"]
},
"voice": "Precise.",
"boundaries": "Article V.",
"evolution_log": []
});
serde_json::from_value(json).unwrap()
}
#[test]
fn roundtrip_json() {
let soul = sample();
let json = serde_json::to_string(&soul).unwrap();
let back: Soul = serde_json::from_str(&json).unwrap();
assert_eq!(back.name.as_str(), "ada");
assert_eq!(back.values.len(), 2);
}
#[test]
fn missing_required_field_errors() {
let json = serde_json::json!({
"name": "ada",
"identity": "x",
"values": [],
"interests": {"communities": []},
});
let result: Result<Soul, _> = serde_json::from_value(json);
assert!(result.is_err());
}
#[test]
fn unknown_community_parses_on_load() {
let json = serde_json::json!({
"name": "ada",
"identity": "x",
"values": ["v"],
"interests": {
"communities": ["tech", "totally-not-a-real-community"],
"topics": []
},
"voice": "v"
});
let soul: Soul = serde_json::from_value(json).unwrap();
assert_eq!(
soul.communities(),
vec![
"tech".to_string(),
"totally-not-a-real-community".to_string()
]
);
}
#[test]
fn validate_communities_warns_on_unknown() {
let soul = sample(); let warnings = soul.validate_communities(&["tech".to_string()]);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].level, WarnLevel::Warning);
assert_eq!(warnings[0].field, "interests.communities");
assert!(warnings[0].message.contains("philosophy"));
}
#[test]
fn validate_communities_all_known_is_clean() {
let soul = sample();
let known = vec!["tech".to_string(), "philosophy".to_string()];
assert!(soul.validate_communities(&known).is_empty());
}
#[test]
fn validate_no_communities_is_error() {
let json = serde_json::json!({
"name": "ada",
"identity": "x",
"values": ["v"],
"interests": {"communities": [], "topics": []},
"voice": "v"
});
let soul: Soul = serde_json::from_value(json).unwrap();
let warnings = soul.validate();
assert!(warnings.iter().any(|w| w.level == WarnLevel::Error));
}
#[test]
fn push_evolution_caps_at_ten() {
let mut soul = sample();
for i in 0..15 {
soul.push_evolution(format!("entry {i}")).unwrap();
}
assert_eq!(soul.evolution_log.len(), EVOLUTION_LOG_CAP);
assert!(soul.evolution_log[0].note.as_str().contains('5'));
assert!(
soul.evolution_log
.last()
.unwrap()
.note
.as_str()
.contains("14")
);
}
#[test]
fn append_evolution_strips_date_prefix() {
let mut soul = sample();
soul.append_evolution("2026-05-03: discovered new thing");
let last = soul.evolution_log.last().unwrap();
assert_eq!(last.note.as_str(), "discovered new thing");
}
#[test]
fn render_includes_canonical_sections() {
let soul = sample();
let md = soul.render();
assert!(md.contains("# ada"));
assert!(md.contains("## Identity"));
assert!(md.contains("## Values"));
assert!(md.contains("## Interests"));
assert!(md.contains("### Communities"));
assert!(md.contains("### Topics"));
assert!(md.contains("## Voice"));
assert!(md.contains("## Boundaries"));
}
#[test]
fn parse_legacy_markdown_works() {
let md = "# ada\n\n## Identity\n\nMethodical.\n\n## Values\n\n- Clarity\n- Evidence\n\n## Interests\n\n- community: tech\n- Systems design\n\n## Voice\n\nPrecise.\n\n## Boundaries\n\nArticle V.\n\n## Evolution Log\n\n- 2026-03-15: Initial creation\n";
let soul = Soul::parse_legacy_markdown(md).unwrap();
assert_eq!(soul.name.as_str(), "ada");
assert_eq!(soul.identity.as_str(), "Methodical.");
assert_eq!(soul.values.len(), 2);
assert_eq!(soul.communities(), vec!["tech".to_string()]);
assert_eq!(soul.interests.topics.len(), 1);
assert_eq!(soul.evolution_log.len(), 1);
}
#[test]
fn parse_legacy_keeps_unknown_community() {
let md = "# t\n\n## Identity\n\ni\n\n## Values\n\n- v\n\n## Interests\n\n- community: not-real\n- community: tech\n\n## Voice\n\nv\n";
let soul = Soul::parse_legacy_markdown(md).unwrap();
assert_eq!(
soul.communities(),
vec!["not-real".to_string(), "tech".to_string()]
);
let warnings = soul.validate_communities(&["tech".to_string()]);
assert!(warnings.iter().any(|w| w.message.contains("not-real")));
}
#[test]
fn parse_legacy_handles_alias() {
let md = "# t\n\n## Identity\n\ni\n\n## Values\n\n- v\n\n## Interests\n\n- community: technology\n\n## Voice\n\nv\n";
let soul = Soul::parse_legacy_markdown(md).unwrap();
assert!(
soul.interests
.communities
.iter()
.any(|c| c.as_str() == "tech")
);
}
#[test]
fn parse_legacy_with_unicode_dash() {
let md = "# t\n\n## Identity\n\ni\n\n## Values\n\n- v\n\n## Interests\n\n- community: meta\u{2011}governance\n\n## Voice\n\nv\n";
let soul = Soul::parse_legacy_markdown(md).unwrap();
assert!(
soul.interests
.communities
.iter()
.any(|c| c.as_str() == "meta-governance")
);
}
#[test]
fn full_schema_includes_communities_field() {
let schema = Soul::full_schema();
let s = schema.to_string();
assert!(s.contains("communities"));
}
#[test]
fn save_and_reload_roundtrip() {
let dir = tempdir();
let path = dir.path().join("SOUL.json");
let soul = sample();
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
soul.save(&path).await.unwrap();
let back = Soul::from_file(&path).await.unwrap();
assert_eq!(back.name.as_str(), soul.name.as_str());
});
}
#[test]
fn save_refuses_invalid_soul() {
let json = serde_json::json!({
"name": "ada",
"identity": "x",
"values": [],
"interests": {"communities": [], "topics": []},
"voice": "v"
});
let soul: Soul = serde_json::from_value(json).unwrap();
let dir = tempdir();
let path = dir.path().join("SOUL.json");
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let result = soul.save(&path).await;
assert!(result.is_err());
});
}
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
}