use std::collections::BTreeMap;
use super::{
escape_value, format_event_into, isoformat_now, parse_links_notation, parse_quoted,
split_first_token, MemoryEvent, BUNDLE_HEADER, ROOT_HEADER,
};
#[must_use]
pub fn export_bundle(seed_files: &[(&str, &str)], events: &[MemoryEvent]) -> String {
let mut out = String::from(BUNDLE_HEADER);
out.push('\n');
out.push_str(" exported_at \"");
out.push_str(&escape_value(&isoformat_now()));
out.push_str("\"\n");
if !seed_files.is_empty() {
out.push_str(" seed_files\n");
for (name, contents) in seed_files {
out.push_str(" file \"");
out.push_str(&escape_value(name));
out.push_str("\"\n");
for line in contents.lines() {
if line.is_empty() {
continue;
}
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
}
out.push_str(" ");
out.push_str(ROOT_HEADER);
out.push('\n');
for event in events {
let mut block = String::new();
format_event_into(event, &mut block);
for line in block.lines() {
if line.is_empty() {
continue;
}
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
out
}
#[must_use]
pub fn extract_memory_from_bundle(text: &str) -> Option<Vec<MemoryEvent>> {
if !text.trim_start().starts_with(BUNDLE_HEADER) {
return None;
}
let mut inner = String::new();
let mut inside = false;
for line in text.lines() {
let indent = line.chars().take_while(|c| *c == ' ').count();
let content = &line[indent..];
if !inside {
if indent == 2 && content == ROOT_HEADER {
inside = true;
inner.push_str(content);
inner.push('\n');
}
continue;
}
if indent <= 2 && !content.starts_with("event ") && !content.is_empty() {
if indent == 2 {
break;
}
}
if indent < 2 {
break;
}
let stripped = line.strip_prefix(" ").unwrap_or(line);
inner.push_str(stripped);
inner.push('\n');
}
if !inside {
return None;
}
Some(parse_links_notation(&inner))
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct BundleInfo {
pub exported_at: Option<String>,
pub version: Option<String>,
pub url: Option<String>,
pub user_agent: Option<String>,
pub worker_state: Option<String>,
pub mode: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ParsedBundle {
pub events: Vec<MemoryEvent>,
pub seed_files: Vec<(String, String)>,
pub preferences: Vec<(String, String)>,
pub info: BundleInfo,
pub agent_info: BTreeMap<String, String>,
}
#[must_use]
pub fn export_full_memory(
seed_files: &[(&str, &str)],
events: &[MemoryEvent],
preferences: &[(&str, &str)],
info: &BundleInfo,
) -> String {
let mut out = String::from(BUNDLE_HEADER);
out.push('\n');
let exported_at = info.exported_at.clone().unwrap_or_else(isoformat_now);
out.push_str(" exported_at \"");
out.push_str(&escape_value(&exported_at));
out.push_str("\"\n");
push_optional_info(&mut out, "version", info.version.as_deref());
push_optional_info(&mut out, "url", info.url.as_deref());
push_optional_info(&mut out, "user_agent", info.user_agent.as_deref());
push_optional_info(&mut out, "worker_state", info.worker_state.as_deref());
push_optional_info(&mut out, "mode", info.mode.as_deref());
if !seed_files.is_empty() {
out.push_str(" seed_files\n");
for (name, contents) in seed_files {
out.push_str(" file \"");
out.push_str(&escape_value(name));
out.push_str("\"\n");
for line in contents.lines() {
if line.is_empty() {
continue;
}
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
}
if !preferences.is_empty() {
out.push_str(" preferences\n");
for (key, value) in preferences {
if key.is_empty() {
continue;
}
out.push_str(" ");
out.push_str(key);
out.push_str(" \"");
out.push_str(&escape_value(value));
out.push_str("\"\n");
}
}
out.push_str(" ");
out.push_str(ROOT_HEADER);
out.push('\n');
for event in events {
let mut block = String::new();
format_event_into(event, &mut block);
for line in block.lines() {
if line.is_empty() {
continue;
}
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
out
}
fn push_optional_info(out: &mut String, key: &str, value: Option<&str>) {
let Some(value) = value else { return };
if value.is_empty() {
return;
}
out.push_str(" ");
out.push_str(key);
out.push_str(" \"");
out.push_str(&escape_value(value));
out.push_str("\"\n");
}
#[must_use]
pub fn import_full_memory(text: &str) -> ParsedBundle {
let trimmed = text.trim_start();
if !trimmed.starts_with(BUNDLE_HEADER) {
return ParsedBundle {
events: parse_links_notation(text),
..ParsedBundle::default()
};
}
parse_bundle_document(text)
}
#[allow(clippy::too_many_lines)]
fn parse_bundle_document(text: &str) -> ParsedBundle {
let mut bundle = ParsedBundle::default();
let mut section: Option<&'static str> = None;
let mut current_seed_file: Option<String> = None;
let mut current_seed_body = String::new();
let mut memory_lines: Vec<String> = Vec::new();
for line in text.lines() {
if line.is_empty() {
if section == Some("seed_files") && current_seed_file.is_some() {
current_seed_body.push('\n');
}
continue;
}
let indent = line.chars().take_while(|c| *c == ' ').count();
let content = &line[indent..];
if indent == 0 {
if let Some(name) = current_seed_file.take() {
bundle
.seed_files
.push((name, std::mem::take(&mut current_seed_body)));
}
section = None;
continue;
}
if indent == 2 {
if let Some(name) = current_seed_file.take() {
bundle
.seed_files
.push((name, std::mem::take(&mut current_seed_body)));
}
if content == "seed_files" {
section = Some("seed_files");
continue;
}
if content == "preferences" {
section = Some("preferences");
continue;
}
if content == ROOT_HEADER {
section = Some("memory");
memory_lines.push(String::from(ROOT_HEADER));
continue;
}
if let Some((key, rest)) = split_first_token(content) {
if let Some(value) = parse_quoted(rest) {
match key {
"exported_at" => bundle.info.exported_at = Some(value),
"version" => bundle.info.version = Some(value),
"url" => bundle.info.url = Some(value),
"user_agent" => bundle.info.user_agent = Some(value),
"worker_state" => bundle.info.worker_state = Some(value),
"mode" => bundle.info.mode = Some(value),
_ => {}
}
}
}
section = None;
continue;
}
match section {
Some("seed_files") => {
if indent == 4 {
if let Some(name) = current_seed_file.take() {
bundle
.seed_files
.push((name, std::mem::take(&mut current_seed_body)));
}
if let Some(rest) = content.strip_prefix("file ") {
if let Some(value) = parse_quoted(rest) {
current_seed_file = Some(value);
current_seed_body = String::new();
}
}
} else if current_seed_file.is_some() && indent >= 6 {
let body = if line.len() >= 6 { &line[6..] } else { "" };
if !current_seed_body.is_empty() {
current_seed_body.push('\n');
}
current_seed_body.push_str(body);
}
}
Some("preferences") if indent == 4 => {
if let Some((key, rest)) = split_first_token(content) {
if let Some(value) = parse_quoted(rest) {
bundle.preferences.push((key.to_string(), value));
}
}
}
Some("memory") => {
let stripped = if line.len() >= 2 { &line[2..] } else { line };
memory_lines.push(stripped.to_string());
}
_ => {}
}
}
if let Some(name) = current_seed_file.take() {
bundle.seed_files.push((name, current_seed_body));
}
if !memory_lines.is_empty() {
bundle.events = parse_links_notation(&memory_lines.join("\n"));
}
bundle.agent_info = extract_agent_info(&bundle.seed_files);
bundle
}
fn extract_agent_info(seed_files: &[(String, String)]) -> BTreeMap<String, String> {
for (name, contents) in seed_files {
if name == "data/seed/agent-info.lino" || name == "seed/agent-info.lino" {
return parse_agent_info(contents);
}
}
BTreeMap::new()
}
fn parse_agent_info(text: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut current_field: Option<String> = None;
for line in text.lines() {
let indent = line.chars().take_while(|c| *c == ' ').count();
let content = &line[indent..];
if indent == 2 {
if let Some(rest) = content.strip_prefix("field ") {
if let Some(value) = parse_quoted(rest) {
current_field = Some(value);
}
}
} else if indent == 4 {
if let Some(rest) = content.strip_prefix("value ") {
if let Some(value) = parse_quoted(rest) {
if let Some(key) = current_field.take() {
out.insert(key, value);
}
}
}
}
}
out
}
#[must_use]
pub fn suggest_migrations(
imported: &ParsedBundle,
current_agent_info: &BTreeMap<String, String>,
) -> Vec<String> {
let mut out = Vec::new();
let imported_version = imported
.agent_info
.get("version")
.cloned()
.or_else(|| imported.info.version.clone());
let current_version = current_agent_info.get("version").cloned();
match (imported_version.as_deref(), current_version.as_deref()) {
(Some(imported_v), Some(current_v)) if imported_v != current_v => {
out.push(format!(
"Seed version {imported_v} → {current_v}: review the new entries in data/seed/ \
(multilingual responses, concepts, tools) — your imported memory was \
authored against an older seed.",
));
}
(Some(imported_v), None) => {
out.push(format!(
"Imported bundle was authored against seed version {imported_v} but the \
running app does not expose a seed version. Update the app to compare.",
));
}
_ => {}
}
if imported.seed_files.is_empty() && !imported.events.is_empty() {
out.push(String::from(
"Imported file is a legacy demo_memory log (no seed). The events were \
imported, but the seed at the time of capture is unknown — export from \
this session to upgrade to a full bundle.",
));
}
out
}
#[cfg(test)]
mod tests {
use super::super::{export_links_notation, MemoryEvent};
use super::{
export_bundle, export_full_memory, extract_memory_from_bundle, import_full_memory,
suggest_migrations, BundleInfo,
};
use std::collections::BTreeMap;
fn sample_events() -> Vec<MemoryEvent> {
vec![
MemoryEvent {
id: String::from("1"),
role: Some(String::from("user")),
content: Some(String::from("Hi")),
sent_at: Some(String::from("2026-05-15T12:00:00.000Z")),
..MemoryEvent::default()
},
MemoryEvent {
id: String::from("2"),
role: Some(String::from("assistant")),
intent: Some(String::from("greeting")),
content: Some(String::from("Hi, how may I help you?")),
sent_at: Some(String::from("2026-05-15T12:00:01.000Z")),
..MemoryEvent::default()
},
]
}
#[test]
fn bundle_export_embeds_seed_and_memory() {
let seed_files: Vec<(&str, &str)> =
vec![("data/seed/example.lino", "example\n key \"v\"")];
let events = sample_events();
let bundle = export_bundle(&seed_files, &events);
assert!(bundle.starts_with("formal_ai_bundle\n"));
assert!(bundle.contains("seed_files"));
assert!(bundle.contains("data/seed/example.lino"));
assert!(bundle.contains("demo_memory"));
assert!(bundle.contains("Hi, how may I help you?"));
}
#[test]
fn extract_memory_from_bundle_recovers_events() {
let seed_files: Vec<(&str, &str)> =
vec![("data/seed/example.lino", "example\n key \"v\"")];
let events = sample_events();
let bundle = export_bundle(&seed_files, &events);
let recovered = extract_memory_from_bundle(&bundle).expect("recover");
assert_eq!(recovered, events);
}
#[test]
fn full_memory_round_trip_preserves_seed_preferences_and_events() {
let seed: Vec<(&str, &str)> = vec![
(
"data/seed/agent-info.lino",
"agent_info\n field \"version\"\n value \"0.22.0\"\n",
),
("data/seed/example.lino", "example\n key \"v\"\n"),
];
let prefs: Vec<(&str, &str)> = vec![("demoMode", "off"), ("diagnosticsMode", "on")];
let events = sample_events();
let info = BundleInfo {
version: Some(String::from("0.22.0")),
url: Some(String::from("https://example.test/")),
user_agent: Some(String::from("playwright/1.0")),
worker_state: Some(String::from("wasm worker")),
mode: Some(String::from("manual")),
..BundleInfo::default()
};
let bundle = export_full_memory(&seed, &events, &prefs, &info);
assert!(bundle.starts_with("formal_ai_bundle\n"));
assert!(bundle.contains("version \"0.22.0\""));
assert!(bundle.contains("user_agent \"playwright/1.0\""));
assert!(bundle.contains("seed_files"));
assert!(bundle.contains("data/seed/agent-info.lino"));
assert!(bundle.contains("preferences"));
assert!(bundle.contains("demoMode \"off\""));
assert!(bundle.contains("demo_memory"));
let parsed = import_full_memory(&bundle);
assert_eq!(parsed.events, events);
assert_eq!(parsed.info.version.as_deref(), Some("0.22.0"));
assert_eq!(parsed.info.mode.as_deref(), Some("manual"));
assert_eq!(
parsed.agent_info.get("version").map(String::as_str),
Some("0.22.0")
);
let prefs_map: BTreeMap<String, String> = parsed.preferences.iter().cloned().collect();
assert_eq!(prefs_map.get("demoMode").map(String::as_str), Some("off"));
assert_eq!(
prefs_map.get("diagnosticsMode").map(String::as_str),
Some("on")
);
assert_eq!(parsed.seed_files.len(), 2);
}
#[test]
fn import_full_memory_accepts_legacy_demo_memory() {
let text = export_links_notation(&sample_events());
let parsed = import_full_memory(&text);
assert_eq!(parsed.events, sample_events());
assert!(parsed.seed_files.is_empty());
assert!(parsed.preferences.is_empty());
assert!(parsed.agent_info.is_empty());
}
#[test]
fn suggest_migrations_flags_seed_version_drift() {
let seed: Vec<(&str, &str)> = vec![(
"data/seed/agent-info.lino",
"agent_info\n field \"version\"\n value \"0.21.0\"\n",
)];
let bundle = export_full_memory(&seed, &sample_events(), &[], &BundleInfo::default());
let imported = import_full_memory(&bundle);
let mut current = BTreeMap::new();
current.insert(String::from("version"), String::from("0.22.0"));
let suggestions = suggest_migrations(&imported, ¤t);
assert_eq!(suggestions.len(), 1);
assert!(suggestions[0].contains("0.21.0"));
assert!(suggestions[0].contains("0.22.0"));
}
#[test]
fn suggest_migrations_flags_legacy_only_import() {
let imported = import_full_memory(&export_links_notation(&sample_events()));
let mut current = BTreeMap::new();
current.insert(String::from("version"), String::from("0.22.0"));
let suggestions = suggest_migrations(&imported, ¤t);
assert!(suggestions
.iter()
.any(|message| message.contains("legacy demo_memory")));
}
#[test]
fn suggest_migrations_is_quiet_when_versions_match() {
let seed: Vec<(&str, &str)> = vec![(
"data/seed/agent-info.lino",
"agent_info\n field \"version\"\n value \"0.22.0\"\n",
)];
let bundle = export_full_memory(&seed, &sample_events(), &[], &BundleInfo::default());
let imported = import_full_memory(&bundle);
let mut current = BTreeMap::new();
current.insert(String::from("version"), String::from("0.22.0"));
assert!(suggest_migrations(&imported, ¤t).is_empty());
}
}