1use std::collections::BTreeMap;
11
12use super::{
13 escape_value, format_event_into, isoformat_now, parse_links_notation, parse_quoted,
14 split_first_token, MemoryEvent, BUNDLE_HEADER, ROOT_HEADER,
15};
16
17#[must_use]
23pub fn export_bundle(seed_files: &[(&str, &str)], events: &[MemoryEvent]) -> String {
24 let mut out = String::from(BUNDLE_HEADER);
25 out.push('\n');
26 out.push_str(" exported_at \"");
27 out.push_str(&escape_value(&isoformat_now()));
28 out.push_str("\"\n");
29 if !seed_files.is_empty() {
30 out.push_str(" seed_files\n");
31 for (name, contents) in seed_files {
32 out.push_str(" file \"");
33 out.push_str(&escape_value(name));
34 out.push_str("\"\n");
35 for line in contents.lines() {
36 if line.is_empty() {
37 continue;
38 }
39 out.push_str(" ");
40 out.push_str(line);
41 out.push('\n');
42 }
43 }
44 }
45 out.push_str(" ");
46 out.push_str(ROOT_HEADER);
47 out.push('\n');
48 for event in events {
49 let mut block = String::new();
52 format_event_into(event, &mut block);
53 for line in block.lines() {
54 if line.is_empty() {
55 continue;
56 }
57 out.push_str(" ");
58 out.push_str(line);
59 out.push('\n');
60 }
61 }
62 out
63}
64
65#[must_use]
71pub fn extract_memory_from_bundle(text: &str) -> Option<Vec<MemoryEvent>> {
72 if !text.trim_start().starts_with(BUNDLE_HEADER) {
73 return None;
74 }
75 let mut inner = String::new();
76 let mut inside = false;
77 for line in text.lines() {
78 let indent = line.chars().take_while(|c| *c == ' ').count();
79 let content = &line[indent..];
80 if !inside {
81 if indent == 2 && content == ROOT_HEADER {
82 inside = true;
83 inner.push_str(content);
84 inner.push('\n');
85 }
86 continue;
87 }
88 if indent <= 2 && !content.starts_with("event ") && !content.is_empty() {
89 if indent == 2 {
92 break;
93 }
94 }
95 if indent < 2 {
96 break;
97 }
98 let stripped = line.strip_prefix(" ").unwrap_or(line);
101 inner.push_str(stripped);
102 inner.push('\n');
103 }
104 if !inside {
105 return None;
106 }
107 Some(parse_links_notation(&inner))
108}
109
110#[derive(Debug, Default, Clone, PartialEq, Eq)]
117pub struct BundleInfo {
118 pub exported_at: Option<String>,
119 pub version: Option<String>,
120 pub url: Option<String>,
121 pub user_agent: Option<String>,
122 pub worker_state: Option<String>,
123 pub mode: Option<String>,
124}
125
126#[derive(Debug, Default, Clone, PartialEq, Eq)]
133pub struct ParsedBundle {
134 pub events: Vec<MemoryEvent>,
135 pub seed_files: Vec<(String, String)>,
136 pub preferences: Vec<(String, String)>,
137 pub info: BundleInfo,
138 pub agent_info: BTreeMap<String, String>,
139}
140
141#[must_use]
150pub fn seed_cache_events(seed_files: &[(String, String)]) -> Vec<MemoryEvent> {
151 seed_files
152 .iter()
153 .map(|(name, contents)| MemoryEvent {
154 id: crate::engine::stable_id("seed_cache", name),
155 kind: Some(String::from("seed_cache")),
156 intent: Some(String::from("seed")),
157 tool: Some(name.clone()),
158 content: Some(contents.clone()),
159 ..MemoryEvent::default()
160 })
161 .collect()
162}
163
164#[must_use]
172pub fn export_full_memory(
173 seed_files: &[(&str, &str)],
174 events: &[MemoryEvent],
175 preferences: &[(&str, &str)],
176 info: &BundleInfo,
177) -> String {
178 let mut out = String::from(BUNDLE_HEADER);
179 out.push('\n');
180 let exported_at = info.exported_at.clone().unwrap_or_else(isoformat_now);
181 out.push_str(" exported_at \"");
182 out.push_str(&escape_value(&exported_at));
183 out.push_str("\"\n");
184 push_optional_info(&mut out, "version", info.version.as_deref());
185 push_optional_info(&mut out, "url", info.url.as_deref());
186 push_optional_info(&mut out, "user_agent", info.user_agent.as_deref());
187 push_optional_info(&mut out, "worker_state", info.worker_state.as_deref());
188 push_optional_info(&mut out, "mode", info.mode.as_deref());
189 if !seed_files.is_empty() {
190 out.push_str(" seed_files\n");
191 for (name, contents) in seed_files {
192 out.push_str(" file \"");
193 out.push_str(&escape_value(name));
194 out.push_str("\"\n");
195 for line in contents.lines() {
196 if line.is_empty() {
197 continue;
198 }
199 out.push_str(" ");
200 out.push_str(line);
201 out.push('\n');
202 }
203 }
204 }
205 if !preferences.is_empty() {
206 out.push_str(" preferences\n");
207 for (key, value) in preferences {
208 if key.is_empty() {
209 continue;
210 }
211 out.push_str(" ");
212 out.push_str(key);
213 out.push_str(" \"");
214 out.push_str(&escape_value(value));
215 out.push_str("\"\n");
216 }
217 }
218 out.push_str(" ");
219 out.push_str(ROOT_HEADER);
220 out.push('\n');
221 for event in events {
222 let mut block = String::new();
223 format_event_into(event, &mut block);
224 for line in block.lines() {
225 if line.is_empty() {
226 continue;
227 }
228 out.push_str(" ");
229 out.push_str(line);
230 out.push('\n');
231 }
232 }
233 out
234}
235
236fn push_optional_info(out: &mut String, key: &str, value: Option<&str>) {
237 let Some(value) = value else { return };
238 if value.is_empty() {
239 return;
240 }
241 out.push_str(" ");
242 out.push_str(key);
243 out.push_str(" \"");
244 out.push_str(&escape_value(value));
245 out.push_str("\"\n");
246}
247
248#[must_use]
255pub fn import_full_memory(text: &str) -> ParsedBundle {
256 let trimmed = text.trim_start();
257 if !trimmed.starts_with(BUNDLE_HEADER) {
258 return ParsedBundle {
259 events: parse_links_notation(text),
260 ..ParsedBundle::default()
261 };
262 }
263 parse_bundle_document(text)
264}
265
266#[allow(clippy::too_many_lines)]
267fn parse_bundle_document(text: &str) -> ParsedBundle {
268 let mut bundle = ParsedBundle::default();
269 let mut section: Option<&'static str> = None;
270 let mut current_seed_file: Option<String> = None;
271 let mut current_seed_body = String::new();
272 let mut memory_lines: Vec<String> = Vec::new();
273 for line in text.lines() {
274 if line.is_empty() {
275 if section == Some("seed_files") && current_seed_file.is_some() {
276 current_seed_body.push('\n');
277 }
278 continue;
279 }
280 let indent = line.chars().take_while(|c| *c == ' ').count();
281 let content = &line[indent..];
282 if indent == 0 {
283 if let Some(name) = current_seed_file.take() {
285 bundle
286 .seed_files
287 .push((name, std::mem::take(&mut current_seed_body)));
288 }
289 section = None;
290 continue;
291 }
292 if indent == 2 {
293 if let Some(name) = current_seed_file.take() {
294 bundle
295 .seed_files
296 .push((name, std::mem::take(&mut current_seed_body)));
297 }
298 if content == "seed_files" {
299 section = Some("seed_files");
300 continue;
301 }
302 if content == "preferences" {
303 section = Some("preferences");
304 continue;
305 }
306 if content == ROOT_HEADER {
307 section = Some("memory");
308 memory_lines.push(String::from(ROOT_HEADER));
309 continue;
310 }
311 if let Some((key, rest)) = split_first_token(content) {
313 if let Some(value) = parse_quoted(rest) {
314 match key {
315 "exported_at" => bundle.info.exported_at = Some(value),
316 "version" => bundle.info.version = Some(value),
317 "url" => bundle.info.url = Some(value),
318 "user_agent" => bundle.info.user_agent = Some(value),
319 "worker_state" => bundle.info.worker_state = Some(value),
320 "mode" => bundle.info.mode = Some(value),
321 _ => {}
322 }
323 }
324 }
325 section = None;
326 continue;
327 }
328 match section {
329 Some("seed_files") => {
330 if indent == 4 {
331 if let Some(name) = current_seed_file.take() {
332 bundle
333 .seed_files
334 .push((name, std::mem::take(&mut current_seed_body)));
335 }
336 if let Some(rest) = content.strip_prefix("file ") {
337 if let Some(value) = parse_quoted(rest) {
338 current_seed_file = Some(value);
339 current_seed_body = String::new();
340 }
341 }
342 } else if current_seed_file.is_some() && indent >= 6 {
343 let body = if line.len() >= 6 { &line[6..] } else { "" };
344 if !current_seed_body.is_empty() {
345 current_seed_body.push('\n');
346 }
347 current_seed_body.push_str(body);
348 }
349 }
350 Some("preferences") if indent == 4 => {
351 if let Some((key, rest)) = split_first_token(content) {
352 if let Some(value) = parse_quoted(rest) {
353 bundle.preferences.push((key.to_string(), value));
354 }
355 }
356 }
357 Some("memory") => {
358 let stripped = if line.len() >= 2 { &line[2..] } else { line };
362 memory_lines.push(stripped.to_string());
363 }
364 _ => {}
365 }
366 }
367 if let Some(name) = current_seed_file.take() {
368 bundle.seed_files.push((name, current_seed_body));
369 }
370 if !memory_lines.is_empty() {
371 bundle.events = parse_links_notation(&memory_lines.join("\n"));
372 }
373 bundle.agent_info = extract_agent_info(&bundle.seed_files);
374 bundle
375}
376
377fn extract_agent_info(seed_files: &[(String, String)]) -> BTreeMap<String, String> {
378 for (name, contents) in seed_files {
379 if name == "data/seed/agent-info.lino" || name == "seed/agent-info.lino" {
380 return parse_agent_info(contents);
381 }
382 }
383 BTreeMap::new()
384}
385
386fn parse_agent_info(text: &str) -> BTreeMap<String, String> {
387 let mut out = BTreeMap::new();
388 let mut current_field: Option<String> = None;
389 for line in text.lines() {
390 let indent = line.chars().take_while(|c| *c == ' ').count();
391 let content = &line[indent..];
392 if indent == 2 {
393 if let Some(rest) = content.strip_prefix("field ") {
394 if let Some(value) = parse_quoted(rest) {
395 current_field = Some(value);
396 }
397 }
398 } else if indent == 4 {
399 if let Some(rest) = content.strip_prefix("value ") {
400 if let Some(value) = parse_quoted(rest) {
401 if let Some(key) = current_field.take() {
402 out.insert(key, value);
403 }
404 }
405 }
406 }
407 }
408 out
409}
410
411#[must_use]
420pub fn suggest_migrations(
421 imported: &ParsedBundle,
422 current_agent_info: &BTreeMap<String, String>,
423) -> Vec<String> {
424 let mut out = Vec::new();
425 let imported_version = imported
426 .agent_info
427 .get("version")
428 .cloned()
429 .or_else(|| imported.info.version.clone());
430 let current_version = current_agent_info.get("version").cloned();
431 match (imported_version.as_deref(), current_version.as_deref()) {
432 (Some(imported_v), Some(current_v)) if imported_v != current_v => {
433 out.push(format!(
434 "Seed version {imported_v} → {current_v}: review the new entries in data/seed/ \
435 (multilingual responses, concepts, tools) — your imported memory was \
436 authored against an older seed.",
437 ));
438 }
439 (Some(imported_v), None) => {
440 out.push(format!(
441 "Imported bundle was authored against seed version {imported_v} but the \
442 running app does not expose a seed version. Update the app to compare.",
443 ));
444 }
445 _ => {}
446 }
447 if imported.seed_files.is_empty() && !imported.events.is_empty() {
448 out.push(String::from(
449 "Imported file is a legacy demo_memory log (no seed). The events were \
450 imported, but the seed at the time of capture is unknown — export from \
451 this session to upgrade to a full bundle.",
452 ));
453 }
454 out
455}