use std::fmt::Write;
use std::path::{Path, PathBuf};
pub const MAX_FILE_CHARS: usize = 20_000;
pub const PERSONALITY_FILES: &[&str] = &[
"SOUL.md",
"IDENTITY.md",
"USER.md",
"AGENTS.md",
"TOOLS.md",
"HEARTBEAT.md",
"MEMORY.md",
];
#[derive(Debug, Clone)]
pub struct PersonalityFile {
pub name: String,
pub content: String,
pub truncated: bool,
pub max_chars_used: usize,
pub path: PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct PersonalityProfile {
pub files: Vec<PersonalityFile>,
pub missing: Vec<String>,
pub empty: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct PersonalityLoadOptions<'a> {
pub files: &'a [&'a str],
pub exclude: &'a [&'a str],
pub conditional: &'a [&'a str],
pub max_chars: usize,
}
impl Default for PersonalityLoadOptions<'_> {
fn default() -> Self {
Self {
files: PERSONALITY_FILES,
exclude: &[],
conditional: &[],
max_chars: MAX_FILE_CHARS,
}
}
}
impl PersonalityProfile {
pub fn get(&self, name: &str) -> Option<&str> {
self.files
.iter()
.find(|f| f.name == name)
.map(|f| f.content.as_str())
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
pub fn render(&self) -> String {
let mut out = String::new();
for file in &self.files {
render_file(&mut out, file);
}
out
}
pub fn render_with_missing_markers(&self, canonical_order: &[&str]) -> String {
let mut out = String::new();
for &name in canonical_order {
if let Some(file) = self.files.iter().find(|f| f.name == name) {
render_file(&mut out, file);
} else if self.missing.iter().any(|m| m == name) {
let _ = writeln!(out, "### {name}\n\n[File not found: {name}]\n");
}
}
out
}
}
fn render_file(out: &mut String, file: &PersonalityFile) {
let _ = writeln!(out, "### {}\n", file.name);
out.push_str(&file.content);
if file.truncated {
let _ = writeln!(
out,
"\n\n[... truncated at {} chars — use `read` for full file]\n",
file.max_chars_used
);
} else {
out.push_str("\n\n");
}
}
pub fn load_personality(workspace_dir: &Path) -> PersonalityProfile {
load_personality_with_options(workspace_dir, &PersonalityLoadOptions::default())
}
pub fn load_personality_files(workspace_dir: &Path, filenames: &[&str]) -> PersonalityProfile {
load_personality_with_options(
workspace_dir,
&PersonalityLoadOptions {
files: filenames,
..PersonalityLoadOptions::default()
},
)
}
pub fn load_personality_with_options(
workspace_dir: &Path,
opts: &PersonalityLoadOptions<'_>,
) -> PersonalityProfile {
let max_chars = if opts.max_chars == 0 {
MAX_FILE_CHARS
} else {
opts.max_chars
};
let mut profile = PersonalityProfile::default();
for &filename in opts.files {
if opts.exclude.iter().any(|e| *e == filename) {
continue;
}
let conditional = opts.conditional.iter().any(|c| *c == filename);
let path = workspace_dir.join(filename);
match std::fs::read_to_string(&path) {
Ok(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
profile.empty.push(filename.to_string());
continue;
}
let (content, truncated) = truncate_content(trimmed, max_chars);
profile.files.push(PersonalityFile {
name: filename.to_string(),
content,
truncated,
max_chars_used: max_chars,
path,
});
}
Err(_) => {
if !conditional {
profile.missing.push(filename.to_string());
}
}
}
}
profile
}
fn truncate_content(content: &str, max_chars: usize) -> (String, bool) {
if content.chars().count() <= max_chars {
return (content.to_string(), false);
}
let truncated = content
.char_indices()
.nth(max_chars)
.map(|(idx, _)| &content[..idx])
.unwrap_or(content);
(truncated.to_string(), true)
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_workspace(files: &[(&str, &str)]) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"construct_personality_test_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).unwrap();
for (name, content) in files {
std::fs::write(dir.join(name), content).unwrap();
}
dir
}
#[test]
fn load_personality_reads_existing_files() {
let ws = setup_workspace(&[
("SOUL.md", "I am a helpful assistant."),
("IDENTITY.md", "Name: Nova"),
]);
let profile = load_personality(&ws);
assert_eq!(profile.files.len(), 2);
assert_eq!(profile.get("SOUL.md").unwrap(), "I am a helpful assistant.");
assert_eq!(profile.get("IDENTITY.md").unwrap(), "Name: Nova");
assert!(!profile.is_empty());
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn load_personality_records_missing_files() {
let ws = setup_workspace(&[("SOUL.md", "soul content")]);
let profile = load_personality(&ws);
assert_eq!(profile.files.len(), 1);
assert!(profile.missing.contains(&"IDENTITY.md".to_string()));
assert!(profile.missing.contains(&"USER.md".to_string()));
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn load_personality_separates_empty_from_missing() {
let ws = setup_workspace(&[("SOUL.md", " \n ")]);
let profile = load_personality(&ws);
assert!(profile.is_empty(), "no loaded files");
assert!(
profile.empty.contains(&"SOUL.md".to_string()),
"SOUL.md should be classified as empty (present but blank)"
);
assert!(
!profile.missing.contains(&"SOUL.md".to_string()),
"SOUL.md must NOT appear in `missing` — that's reserved for files \
that don't exist on disk"
);
assert!(
profile.missing.contains(&"IDENTITY.md".to_string()),
"IDENTITY.md is genuinely missing-on-disk"
);
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn load_personality_truncates_large_files() {
let large = "x".repeat(MAX_FILE_CHARS + 500);
let ws = setup_workspace(&[("SOUL.md", &large)]);
let profile = load_personality(&ws);
let soul = profile.files.iter().find(|f| f.name == "SOUL.md").unwrap();
assert!(soul.truncated);
assert_eq!(soul.content.chars().count(), MAX_FILE_CHARS);
assert_eq!(soul.max_chars_used, MAX_FILE_CHARS);
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn render_produces_markdown_sections() {
let ws = setup_workspace(&[("SOUL.md", "Be kind."), ("IDENTITY.md", "Name: Nova")]);
let profile = load_personality(&ws);
let rendered = profile.render();
assert!(rendered.contains("### SOUL.md"));
assert!(rendered.contains("Be kind."));
assert!(rendered.contains("### IDENTITY.md"));
assert!(rendered.contains("Name: Nova"));
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn render_truncated_file_shows_notice() {
let large = "y".repeat(MAX_FILE_CHARS + 100);
let ws = setup_workspace(&[("SOUL.md", &large)]);
let profile = load_personality(&ws);
let rendered = profile.render();
assert!(rendered.contains("[... truncated at"));
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn render_truncation_marker_uses_per_file_cap() {
let large = "z".repeat(7_000);
let ws = setup_workspace(&[("SOUL.md", &large)]);
let profile = load_personality_with_options(
&ws,
&PersonalityLoadOptions {
files: &["SOUL.md"],
max_chars: 6_000,
..PersonalityLoadOptions::default()
},
);
let rendered = profile.render();
assert!(
rendered.contains("[... truncated at 6000 chars"),
"truncation marker must reflect the cap actually used during load"
);
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn get_returns_none_for_missing_file() {
let ws = setup_workspace(&[]);
let profile = load_personality(&ws);
assert!(profile.get("SOUL.md").is_none());
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn load_personality_files_custom_subset() {
let ws = setup_workspace(&[("SOUL.md", "soul"), ("USER.md", "user")]);
let profile = load_personality_files(&ws, &["SOUL.md", "USER.md"]);
assert_eq!(profile.files.len(), 2);
assert!(profile.missing.is_empty());
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn empty_workspace_yields_empty_profile() {
let ws = setup_workspace(&[]);
let profile = load_personality(&ws);
assert!(profile.is_empty());
assert!(!profile.missing.is_empty());
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn load_personality_with_options_excludes_filter_skips_silently() {
let ws = setup_workspace(&[("SOUL.md", "soul"), ("HEARTBEAT.md", "ignore me")]);
let profile = load_personality_with_options(
&ws,
&PersonalityLoadOptions {
files: PERSONALITY_FILES,
exclude: &["HEARTBEAT.md"],
conditional: &[],
max_chars: 0,
},
);
assert!(
!profile.files.iter().any(|f| f.name == "HEARTBEAT.md"),
"excluded file must not appear in profile.files"
);
assert!(
!profile.missing.contains(&"HEARTBEAT.md".to_string()),
"excluded file must not appear in profile.missing"
);
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn load_personality_with_options_conditional_missing_is_silent() {
let ws = setup_workspace(&[("SOUL.md", "soul")]);
let custom_files: &[&str] = &["SOUL.md", "OPTIONAL.md", "IDENTITY.md"];
let profile = load_personality_with_options(
&ws,
&PersonalityLoadOptions {
files: custom_files,
exclude: &[],
conditional: &["OPTIONAL.md"],
max_chars: 0,
},
);
assert!(
!profile.missing.contains(&"OPTIONAL.md".to_string()),
"conditional+missing must not record a missing marker"
);
assert!(profile.missing.contains(&"IDENTITY.md".to_string()));
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn render_with_missing_markers_interleaves_in_canonical_order() {
let ws = setup_workspace(&[("SOUL.md", "soul"), ("USER.md", "user")]);
let profile = load_personality_with_options(
&ws,
&PersonalityLoadOptions {
files: PERSONALITY_FILES,
exclude: &["HEARTBEAT.md"],
conditional: &[],
max_chars: 0,
},
);
let rendered = profile.render_with_missing_markers(PERSONALITY_FILES);
let soul_idx = rendered.find("### SOUL.md").expect("SOUL header");
let id_idx = rendered.find("### IDENTITY.md").expect("IDENTITY marker");
let user_idx = rendered.find("### USER.md").expect("USER header");
let agents_idx = rendered.find("### AGENTS.md").expect("AGENTS marker");
let memory_idx = rendered.find("### MEMORY.md").expect("MEMORY marker");
assert!(soul_idx < id_idx);
assert!(id_idx < user_idx);
assert!(user_idx < agents_idx);
assert!(agents_idx < memory_idx);
assert!(
!rendered.contains("HEARTBEAT.md"),
"excluded file must not appear in rendered output"
);
assert!(
!rendered.contains("BOOTSTRAP.md"),
"deleted file must never appear in rendered output"
);
assert!(rendered.contains("[File not found: IDENTITY.md]"));
assert!(rendered.contains("[File not found: AGENTS.md]"));
assert!(rendered.contains("[File not found: MEMORY.md]"));
let _ = std::fs::remove_dir_all(ws);
}
#[test]
fn render_skips_empty_files_silently_in_both_modes() {
let ws = setup_workspace(&[("SOUL.md", "soul"), ("TOOLS.md", "")]);
let profile = load_personality_with_options(
&ws,
&PersonalityLoadOptions {
files: &["SOUL.md", "TOOLS.md"],
..PersonalityLoadOptions::default()
},
);
assert!(profile.empty.contains(&"TOOLS.md".to_string()));
assert!(!profile.missing.contains(&"TOOLS.md".to_string()));
let daemon = profile.render();
let channel = profile.render_with_missing_markers(&["SOUL.md", "TOOLS.md"]);
assert!(!daemon.contains("TOOLS.md"));
assert!(!channel.contains("TOOLS.md"));
let _ = std::fs::remove_dir_all(ws);
}
}