cobolx 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,8 @@
1
1
  use crate::cobol::lexer::{clean_name, logical_lines, tokenize};
2
- use crate::cobol::model::{CallKind, ParsedCall, ParsedCopy, ParsedFile, ParsedProgram, Token};
2
+ use crate::cobol::model::{
3
+ CallKind, CodeBlockKind, ExternalOpKind, ParsedCall, ParsedCodeBlock, ParsedCopy,
4
+ ParsedExternalOp, ParsedFile, ParsedIdentifier, ParsedLiteral, ParsedProgram, Token,
5
+ };
3
6
  use std::path::Path;
4
7
 
5
8
  pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
@@ -7,7 +10,14 @@ pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
7
10
  let mut programs = Vec::new();
8
11
  let mut copies = Vec::new();
9
12
  let mut calls = Vec::new();
13
+ let mut code_blocks = Vec::new();
14
+ let mut external_ops = Vec::new();
15
+ let mut identifiers = Vec::new();
16
+ let mut literals = Vec::new();
10
17
  let mut current_program = None::<String>;
18
+ let mut in_procedure_division = false;
19
+ let mut current_section = None::<String>;
20
+ let mut current_block_idx = None::<usize>;
11
21
 
12
22
  for line in logical_lines(&content) {
13
23
  let tokens = tokenize(&line.text);
@@ -15,6 +25,54 @@ pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
15
25
  continue;
16
26
  }
17
27
 
28
+ if has_two_tokens(&tokens, "PROCEDURE", "DIVISION") {
29
+ in_procedure_division = true;
30
+ current_section = None;
31
+ current_block_idx = None;
32
+ continue;
33
+ }
34
+
35
+ if in_procedure_division {
36
+ if let Some(section_name) = parse_section_name(&tokens) {
37
+ code_blocks.push(ParsedCodeBlock {
38
+ caller_name: current_program.clone(),
39
+ name: section_name.clone(),
40
+ kind: CodeBlockKind::Section,
41
+ parent_section: None,
42
+ start_offset: line.start_offset + tokens[0].start,
43
+ byte_len: line.byte_len,
44
+ statement_count: 0,
45
+ });
46
+ current_section = Some(section_name);
47
+ current_block_idx = Some(code_blocks.len() - 1);
48
+ continue;
49
+ }
50
+
51
+ if let Some(paragraph_name) = parse_paragraph_name(&line.text, &tokens) {
52
+ identifiers.push(ParsedIdentifier {
53
+ caller_name: current_program.clone(),
54
+ kind: "paragraph_name".to_string(),
55
+ value: paragraph_name.clone(),
56
+ start_offset: line.start_offset + tokens[0].start,
57
+ });
58
+ code_blocks.push(ParsedCodeBlock {
59
+ caller_name: current_program.clone(),
60
+ name: paragraph_name,
61
+ kind: CodeBlockKind::Paragraph,
62
+ parent_section: current_section.clone(),
63
+ start_offset: line.start_offset + tokens[0].start,
64
+ byte_len: line.byte_len,
65
+ statement_count: 0,
66
+ });
67
+ current_block_idx = Some(code_blocks.len() - 1);
68
+ continue;
69
+ }
70
+
71
+ if let Some(block_idx) = current_block_idx {
72
+ code_blocks[block_idx].statement_count += 1;
73
+ }
74
+ }
75
+
18
76
  for idx in 0..tokens.len() {
19
77
  match tokens[idx].text.as_str() {
20
78
  "PROGRAM-ID" => {
@@ -37,6 +95,7 @@ pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
37
95
  .find(|t| t.text == "REPLACING")
38
96
  .map(|t| line.text[t.start..].trim().to_string());
39
97
  copies.push(ParsedCopy {
98
+ caller_name: current_program.clone(),
40
99
  name,
41
100
  start_offset: line.start_offset + tokens[idx].start,
42
101
  byte_len: line.byte_len,
@@ -56,18 +115,89 @@ pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
56
115
  };
57
116
  calls.push(ParsedCall {
58
117
  caller_name: current_program.clone(),
59
- target: name,
118
+ target: name.clone(),
60
119
  kind,
61
120
  start_offset: line.start_offset + tokens[idx].start,
62
121
  byte_len: line.byte_len,
63
122
  using_count: count_using_args(&tokens[idx + 2..]),
64
123
  });
124
+ external_ops.push(ParsedExternalOp {
125
+ caller_name: current_program.clone(),
126
+ kind: if target.quoted {
127
+ ExternalOpKind::CallLiteral
128
+ } else {
129
+ ExternalOpKind::CallIdentifier
130
+ },
131
+ verb: "CALL".to_string(),
132
+ target: Some(name.clone()),
133
+ start_offset: line.start_offset + tokens[idx].start,
134
+ byte_len: line.byte_len,
135
+ });
136
+ if target.quoted {
137
+ literals.push(ParsedLiteral {
138
+ caller_name: current_program.clone(),
139
+ kind: "call_target".to_string(),
140
+ value: name,
141
+ start_offset: line.start_offset + target.start,
142
+ });
143
+ } else {
144
+ identifiers.push(ParsedIdentifier {
145
+ caller_name: current_program.clone(),
146
+ kind: "call_target_identifier".to_string(),
147
+ value: name,
148
+ start_offset: line.start_offset + target.start,
149
+ });
150
+ }
65
151
  }
66
152
  }
67
153
  }
68
154
  _ => {}
69
155
  }
70
156
  }
157
+
158
+ if !in_procedure_division {
159
+ continue;
160
+ }
161
+
162
+ if let Some((op, mut ids, mut lits)) = parse_exec_sql(
163
+ &tokens,
164
+ line.start_offset,
165
+ line.byte_len,
166
+ current_program.clone(),
167
+ ) {
168
+ external_ops.push(op);
169
+ identifiers.append(&mut ids);
170
+ literals.append(&mut lits);
171
+ continue;
172
+ }
173
+
174
+ if let Some((op, mut lits)) = parse_exec_cics(
175
+ &tokens,
176
+ line.start_offset,
177
+ line.byte_len,
178
+ current_program.clone(),
179
+ ) {
180
+ external_ops.push(op);
181
+ literals.append(&mut lits);
182
+ continue;
183
+ }
184
+
185
+ if let Some((verb, target)) = parse_file_io(&tokens) {
186
+ external_ops.push(ParsedExternalOp {
187
+ caller_name: current_program.clone(),
188
+ kind: ExternalOpKind::FileIo,
189
+ verb: verb.to_string(),
190
+ target: Some(target.clone()),
191
+ start_offset: line.start_offset + tokens[0].start,
192
+ byte_len: line.byte_len,
193
+ });
194
+ identifiers.push(ParsedIdentifier {
195
+ caller_name: current_program.clone(),
196
+ kind: "file_name".to_string(),
197
+ value: target,
198
+ start_offset: line.start_offset + tokens[1].start,
199
+ });
200
+ }
71
201
  }
72
202
 
73
203
  Ok(ParsedFile {
@@ -75,6 +205,10 @@ pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
75
205
  programs,
76
206
  copies,
77
207
  calls,
208
+ code_blocks,
209
+ external_ops,
210
+ identifiers,
211
+ literals,
78
212
  })
79
213
  }
80
214
 
@@ -89,3 +223,208 @@ fn count_using_args(tokens: &[Token]) -> usize {
89
223
  .filter(|t| !matches!(t.text.as_str(), "BY" | "REFERENCE" | "CONTENT" | "VALUE"))
90
224
  .count()
91
225
  }
226
+
227
+ fn has_two_tokens(tokens: &[Token], first: &str, second: &str) -> bool {
228
+ tokens.len() >= 2 && tokens[0].text == first && tokens[1].text == second
229
+ }
230
+
231
+ fn parse_section_name(tokens: &[Token]) -> Option<String> {
232
+ (tokens.len() >= 2 && tokens[1].text == "SECTION").then(|| clean_name(&tokens[0].text))
233
+ }
234
+
235
+ fn parse_paragraph_name(line: &str, tokens: &[Token]) -> Option<String> {
236
+ if tokens.len() != 1 || !line.trim_end().ends_with('.') {
237
+ return None;
238
+ }
239
+
240
+ let name = clean_name(&tokens[0].text);
241
+ (!name.is_empty() && !is_reserved_label(&name)).then_some(name)
242
+ }
243
+
244
+ fn is_reserved_label(name: &str) -> bool {
245
+ matches!(
246
+ name,
247
+ "ACCEPT"
248
+ | "ADD"
249
+ | "CALL"
250
+ | "CANCEL"
251
+ | "CLOSE"
252
+ | "COMPUTE"
253
+ | "CONTINUE"
254
+ | "DELETE"
255
+ | "DISPLAY"
256
+ | "DIVIDE"
257
+ | "ELSE"
258
+ | "END-CALL"
259
+ | "END-EVALUATE"
260
+ | "END-IF"
261
+ | "END-PERFORM"
262
+ | "ENTRY"
263
+ | "EVALUATE"
264
+ | "EXEC"
265
+ | "EXIT"
266
+ | "GOBACK"
267
+ | "GO"
268
+ | "IF"
269
+ | "INITIALIZE"
270
+ | "INSPECT"
271
+ | "MERGE"
272
+ | "MOVE"
273
+ | "MULTIPLY"
274
+ | "OPEN"
275
+ | "PERFORM"
276
+ | "PROCEDURE"
277
+ | "READ"
278
+ | "RELEASE"
279
+ | "RETURN"
280
+ | "REWRITE"
281
+ | "SEARCH"
282
+ | "SORT"
283
+ | "START"
284
+ | "STOP"
285
+ | "STRING"
286
+ | "SUBTRACT"
287
+ | "UNSTRING"
288
+ | "USE"
289
+ | "WHEN"
290
+ | "WRITE"
291
+ )
292
+ }
293
+
294
+ fn parse_exec_sql(
295
+ tokens: &[Token],
296
+ line_start_offset: usize,
297
+ byte_len: usize,
298
+ caller_name: Option<String>,
299
+ ) -> Option<(ParsedExternalOp, Vec<ParsedIdentifier>, Vec<ParsedLiteral>)> {
300
+ if !has_two_tokens(tokens, "EXEC", "SQL") {
301
+ return None;
302
+ }
303
+
304
+ let verb = tokens[2..]
305
+ .iter()
306
+ .find(|t| {
307
+ matches!(
308
+ t.text.as_str(),
309
+ "SELECT" | "INSERT" | "UPDATE" | "DELETE" | "MERGE"
310
+ )
311
+ })
312
+ .map(|t| t.text.clone())
313
+ .unwrap_or_else(|| "SQL".to_string());
314
+ let target = extract_sql_target(tokens, &verb);
315
+ let mut identifiers = Vec::new();
316
+ if let Some(ref table) = target {
317
+ identifiers.push(ParsedIdentifier {
318
+ caller_name: caller_name.clone(),
319
+ kind: "sql_table".to_string(),
320
+ value: table.clone(),
321
+ start_offset: line_start_offset,
322
+ });
323
+ }
324
+ let literals = tokens
325
+ .iter()
326
+ .filter(|t| t.quoted)
327
+ .map(|t| ParsedLiteral {
328
+ caller_name: caller_name.clone(),
329
+ kind: "string_literal".to_string(),
330
+ value: clean_name(&t.text),
331
+ start_offset: line_start_offset + t.start,
332
+ })
333
+ .collect::<Vec<_>>();
334
+
335
+ Some((
336
+ ParsedExternalOp {
337
+ caller_name,
338
+ kind: ExternalOpKind::ExecSql,
339
+ verb,
340
+ target,
341
+ start_offset: line_start_offset,
342
+ byte_len,
343
+ },
344
+ identifiers,
345
+ literals,
346
+ ))
347
+ }
348
+
349
+ fn extract_sql_target(tokens: &[Token], verb: &str) -> Option<String> {
350
+ match verb {
351
+ "SELECT" => token_after_keyword(tokens, "FROM"),
352
+ "INSERT" => token_after_keyword(tokens, "INTO"),
353
+ "UPDATE" => token_after_keyword(tokens, "UPDATE"),
354
+ "DELETE" => token_after_keyword(tokens, "FROM"),
355
+ "MERGE" => {
356
+ token_after_keyword(tokens, "INTO").or_else(|| token_after_keyword(tokens, "USING"))
357
+ }
358
+ _ => None,
359
+ }
360
+ }
361
+
362
+ fn parse_exec_cics(
363
+ tokens: &[Token],
364
+ line_start_offset: usize,
365
+ byte_len: usize,
366
+ caller_name: Option<String>,
367
+ ) -> Option<(ParsedExternalOp, Vec<ParsedLiteral>)> {
368
+ if !has_two_tokens(tokens, "EXEC", "CICS") {
369
+ return None;
370
+ }
371
+
372
+ let verb = tokens
373
+ .get(2)
374
+ .map(|t| clean_name(&t.text))
375
+ .filter(|s| !s.is_empty())
376
+ .unwrap_or_else(|| "CICS".to_string());
377
+ let target_idx = tokens.iter().position(|t| t.text == "PROGRAM");
378
+ let target = target_idx
379
+ .and_then(|idx| tokens.get(idx + 1))
380
+ .map(|t| clean_name(&t.text))
381
+ .filter(|s| !s.is_empty());
382
+ let literals = target_idx
383
+ .and_then(|idx| tokens.get(idx + 1))
384
+ .filter(|t| t.quoted)
385
+ .map(|t| ParsedLiteral {
386
+ caller_name: caller_name.clone(),
387
+ kind: "exec_cics_program".to_string(),
388
+ value: clean_name(&t.text),
389
+ start_offset: line_start_offset + t.start,
390
+ })
391
+ .into_iter()
392
+ .collect::<Vec<_>>();
393
+
394
+ Some((
395
+ ParsedExternalOp {
396
+ caller_name,
397
+ kind: ExternalOpKind::ExecCics,
398
+ verb,
399
+ target,
400
+ start_offset: line_start_offset,
401
+ byte_len,
402
+ },
403
+ literals,
404
+ ))
405
+ }
406
+
407
+ fn parse_file_io(tokens: &[Token]) -> Option<(String, String)> {
408
+ match tokens.first()?.text.as_str() {
409
+ "OPEN" => {
410
+ let mode_idx = tokens
411
+ .iter()
412
+ .position(|t| matches!(t.text.as_str(), "INPUT" | "OUTPUT" | "I-O" | "EXTEND"))?;
413
+ let target = tokens.get(mode_idx + 1).map(|t| clean_name(&t.text))?;
414
+ (!target.is_empty()).then_some(("OPEN".to_string(), target))
415
+ }
416
+ "READ" | "WRITE" | "REWRITE" | "DELETE" | "START" | "CLOSE" | "RETURN" | "RELEASE" => {
417
+ let target = tokens.get(1).map(|t| clean_name(&t.text))?;
418
+ (!target.is_empty()).then_some((tokens[0].text.clone(), target))
419
+ }
420
+ _ => None,
421
+ }
422
+ }
423
+
424
+ fn token_after_keyword(tokens: &[Token], keyword: &str) -> Option<String> {
425
+ let idx = tokens.iter().position(|t| t.text == keyword)?;
426
+ tokens
427
+ .get(idx + 1)
428
+ .map(|t| clean_name(&t.text))
429
+ .filter(|s| !s.is_empty())
430
+ }
package/src/lib.rs CHANGED
@@ -3,4 +3,5 @@ pub mod agent;
3
3
  pub mod cobol;
4
4
  pub mod config;
5
5
  pub mod memory;
6
+ pub mod path_safety;
6
7
  pub mod ui;
package/src/main.rs CHANGED
@@ -2,6 +2,7 @@ mod agent;
2
2
  mod cobol;
3
3
  mod config;
4
4
  mod memory;
5
+ mod path_safety;
5
6
  mod ui;
6
7
 
7
8
  #[tokio::main]
@@ -0,0 +1,208 @@
1
+ use std::error::Error;
2
+ use std::path::{Path, PathBuf};
3
+
4
+ type MemResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
5
+
6
+ /// Codex-style: consolidate when cumulative session tokens exceed this.
7
+ pub const TOKEN_SUMMARY_THRESHOLD: u32 = 8_000;
8
+
9
+ /// Codex injects ~5k tokens of `memory_summary.md`; ~4 chars/token.
10
+ pub const MEMORY_SUMMARY_INJECT_MAX_CHARS: usize = 20_000;
11
+
12
+ pub const MEMORY_SUMMARY_FILE: &str = "memory_summary.md";
13
+ pub const MEMORY_HANDBOOK_FILE: &str = "MEMORY.md";
14
+ pub const RAW_MEMORIES_FILE: &str = "raw_memories.md";
15
+ pub const AGENTS_FILE: &str = "AGENTS.md";
16
+ pub const LEGACY_SUMMARY_FILE: &str = "SUMMARY_MEM.md";
17
+
18
+ pub const CONSOLIDATION_SUMMARY_MARKER: &str = "---COBOLX_MEMORY_SUMMARY---";
19
+ pub const CONSOLIDATION_HANDBOOK_MARKER: &str = "---COBOLX_MEMORY_HANDBOOK---";
20
+
21
+ /// Codex-aligned memory layout under `.cobolx/memories/`.
22
+ pub struct CodexMemories {
23
+ memories_dir: PathBuf,
24
+ rollout_summaries_dir: PathBuf,
25
+ project_root: PathBuf,
26
+ legacy_summary_path: PathBuf,
27
+ }
28
+
29
+ impl CodexMemories {
30
+ pub fn for_project(base_dir: &Path, project_root: &Path) -> Self {
31
+ let memories_dir = base_dir.join("memories");
32
+ let rollout_summaries_dir = memories_dir.join("rollout_summaries");
33
+ Self {
34
+ memories_dir,
35
+ rollout_summaries_dir,
36
+ project_root: project_root.to_path_buf(),
37
+ legacy_summary_path: base_dir.join(LEGACY_SUMMARY_FILE),
38
+ }
39
+ }
40
+
41
+ pub fn memories_dir(&self) -> &Path {
42
+ &self.memories_dir
43
+ }
44
+
45
+ pub fn rollout_summaries_dir(&self) -> &Path {
46
+ &self.rollout_summaries_dir
47
+ }
48
+
49
+ pub fn ensure_layout(&self) -> MemResult<()> {
50
+ std::fs::create_dir_all(&self.memories_dir)?;
51
+ std::fs::create_dir_all(&self.rollout_summaries_dir)?;
52
+
53
+ let summary_path = self.memory_summary_path();
54
+ if !summary_path.exists() {
55
+ if self.legacy_summary_path.exists() {
56
+ let legacy = std::fs::read_to_string(&self.legacy_summary_path)?;
57
+ std::fs::write(&summary_path, legacy)?;
58
+ } else {
59
+ std::fs::write(&summary_path, default_memory_summary())?;
60
+ }
61
+ }
62
+
63
+ let handbook_path = self.memory_handbook_path();
64
+ if !handbook_path.exists() {
65
+ std::fs::write(&handbook_path, default_memory_handbook())?;
66
+ }
67
+
68
+ Ok(())
69
+ }
70
+
71
+ pub fn read_agents_instructions(&self) -> Option<String> {
72
+ let path = self.project_root.join(AGENTS_FILE);
73
+ if path.exists() {
74
+ std::fs::read_to_string(path).ok()
75
+ } else {
76
+ None
77
+ }
78
+ }
79
+
80
+ /// Short summary injected each prompt (Codex: `memory_summary.md`, capped).
81
+ pub fn read_memory_summary_for_injection(&self) -> MemResult<String> {
82
+ let raw = std::fs::read_to_string(self.memory_summary_path())?;
83
+ Ok(truncate_utf8_prefix(&raw, MEMORY_SUMMARY_INJECT_MAX_CHARS))
84
+ }
85
+
86
+ pub fn read_memory_summary(&self) -> MemResult<String> {
87
+ Ok(std::fs::read_to_string(self.memory_summary_path())?)
88
+ }
89
+
90
+ pub fn read_memory_handbook(&self) -> MemResult<String> {
91
+ Ok(std::fs::read_to_string(self.memory_handbook_path())?)
92
+ }
93
+
94
+ /// Phase 1: per-run recap (Codex: `rollout_summaries/{id}.md`).
95
+ pub fn write_rollout_summary(&self, run_id: &str, content: &str) -> MemResult<PathBuf> {
96
+ let path = self.rollout_summaries_dir.join(format!("{}.md", run_id));
97
+ std::fs::write(&path, content)?;
98
+ Ok(path)
99
+ }
100
+
101
+ pub fn write_raw_memories(&self, content: &str) -> MemResult<()> {
102
+ std::fs::write(self.memories_dir.join(RAW_MEMORIES_FILE), content)?;
103
+ Ok(())
104
+ }
105
+
106
+ pub fn write_consolidated(&self, memory_summary: &str, memory_handbook: &str) -> MemResult<()> {
107
+ std::fs::write(self.memory_summary_path(), memory_summary)?;
108
+ std::fs::write(self.memory_handbook_path(), memory_handbook)?;
109
+ Ok(())
110
+ }
111
+
112
+ pub fn parse_consolidation_output(output: &str) -> Option<(String, String)> {
113
+ let summary_start = output.find(CONSOLIDATION_SUMMARY_MARKER)?;
114
+ let handbook_start = output.find(CONSOLIDATION_HANDBOOK_MARKER)?;
115
+ if handbook_start <= summary_start {
116
+ return None;
117
+ }
118
+ let summary = output[summary_start + CONSOLIDATION_SUMMARY_MARKER.len()..handbook_start]
119
+ .trim()
120
+ .to_string();
121
+ let handbook = output[handbook_start + CONSOLIDATION_HANDBOOK_MARKER.len()..]
122
+ .trim()
123
+ .to_string();
124
+ if summary.is_empty() || handbook.is_empty() {
125
+ return None;
126
+ }
127
+ Some((summary, handbook))
128
+ }
129
+
130
+ fn memory_summary_path(&self) -> PathBuf {
131
+ self.memories_dir.join(MEMORY_SUMMARY_FILE)
132
+ }
133
+
134
+ fn memory_handbook_path(&self) -> PathBuf {
135
+ self.memories_dir.join(MEMORY_HANDBOOK_FILE)
136
+ }
137
+ }
138
+
139
+ pub fn default_memory_summary() -> String {
140
+ "# COBOLX Memory Summary\n\n\
141
+ Navigational summary for the next session (Codex-style `memory_summary.md`).\n\n\
142
+ - last_updated: (none)\n\
143
+ - tokens_summarized: 0\n\n\
144
+ ## Context\n\n\
145
+ (none yet)\n\n\
146
+ ## Key findings\n\n\
147
+ (none yet)\n"
148
+ .to_string()
149
+ }
150
+
151
+ pub fn default_memory_handbook() -> String {
152
+ "# COBOLX Memory Handbook\n\n\
153
+ Long-form project memory (Codex-style `MEMORY.md`). You may edit manually.\n\n\
154
+ ## Project\n\n\
155
+ (none yet)\n\n\
156
+ ## COBOL programs\n\n\
157
+ (none yet)\n\n\
158
+ ## Open questions\n\n\
159
+ (none yet)\n"
160
+ .to_string()
161
+ }
162
+
163
+ fn truncate_utf8_prefix(content: &str, max_bytes: usize) -> String {
164
+ if content.len() <= max_bytes {
165
+ return content.to_string();
166
+ }
167
+ let mut end = max_bytes;
168
+ while end > 0 && !content.is_char_boundary(end) {
169
+ end -= 1;
170
+ }
171
+ format!("{}…\n\n[truncated for context budget]", &content[..end])
172
+ }
173
+
174
+ #[cfg(test)]
175
+ mod tests {
176
+ use super::*;
177
+ use tempfile::tempdir;
178
+
179
+ #[test]
180
+ fn codex_memories_layout_and_rollout_summary() {
181
+ let dir = tempdir().unwrap();
182
+ let base = dir.path().join(".cobolx");
183
+ let mem = CodexMemories::for_project(&base, dir.path());
184
+ mem.ensure_layout().unwrap();
185
+
186
+ assert!(mem.memories_dir().join(MEMORY_SUMMARY_FILE).exists());
187
+ assert!(mem.memories_dir().join(MEMORY_HANDBOOK_FILE).exists());
188
+
189
+ mem.write_rollout_summary("20250626T120000", "# rollout recap\n")
190
+ .unwrap();
191
+ assert!(
192
+ mem.rollout_summaries_dir()
193
+ .join("20250626T120000.md")
194
+ .exists()
195
+ );
196
+ }
197
+
198
+ #[test]
199
+ fn parses_consolidation_markers() {
200
+ let out = format!(
201
+ "preamble\n{}\nsummary body\n{}\nhandbook body",
202
+ CONSOLIDATION_SUMMARY_MARKER, CONSOLIDATION_HANDBOOK_MARKER
203
+ );
204
+ let (s, h) = CodexMemories::parse_consolidation_output(&out).unwrap();
205
+ assert_eq!(s, "summary body");
206
+ assert_eq!(h, "handbook body");
207
+ }
208
+ }