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.
package/src/ui/tui.rs CHANGED
@@ -1,4 +1,5 @@
1
1
  use crate::agent::client::AgentRouter;
2
+ use crate::memory::{RunJournal, TOKEN_SUMMARY_THRESHOLD};
2
3
  use crate::ui::draw;
3
4
  use chrono::Local;
4
5
  use crossterm::{
@@ -7,6 +8,7 @@ use crossterm::{
7
8
  terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
8
9
  };
9
10
  use ratatui::{Terminal, backend::CrosstermBackend};
11
+ use serde_json::json;
10
12
  use std::io;
11
13
  use std::sync::Arc;
12
14
 
@@ -52,6 +54,10 @@ pub enum TaskUpdate {
52
54
  Result<Option<crate::agent::client::Usage>, String>,
53
55
  &'static str,
54
56
  ),
57
+ MemorySummarized {
58
+ ok: bool,
59
+ detail: String,
60
+ },
55
61
  }
56
62
 
57
63
  pub struct App {
@@ -80,6 +86,8 @@ pub struct App {
80
86
  pub spinner_tick: usize,
81
87
  pub console_scroll_offset: u16,
82
88
  pub custom_prompt_override: Option<String>,
89
+ pub run_journal: Option<RunJournal>,
90
+ pub tokens_since_summary: u32,
83
91
  }
84
92
 
85
93
  impl App {
@@ -142,6 +150,38 @@ impl App {
142
150
  spinner_tick: 0,
143
151
  console_scroll_offset: 0,
144
152
  custom_prompt_override: None,
153
+ run_journal: None,
154
+ tokens_since_summary: 0,
155
+ }
156
+ }
157
+
158
+ fn start_run_journal(&mut self, sandbox: &std::path::Path) {
159
+ if let Ok(store) = crate::memory::MemoryStore::open_or_create(sandbox) {
160
+ let _ = store.codex_memories().ensure_layout();
161
+ if let Ok(journal) = RunJournal::start(store.runs_dir()) {
162
+ self.run_journal = Some(journal);
163
+ }
164
+ }
165
+ }
166
+
167
+ fn log_run(&mut self, kind: &str, payload: serde_json::Value) {
168
+ if let Some(journal) = &mut self.run_journal {
169
+ journal.log(kind, payload);
170
+ }
171
+ }
172
+
173
+ fn finish_run_journal(&mut self, status: &str) {
174
+ if let Some(journal) = &self.run_journal {
175
+ journal.finish(status);
176
+ if let Some(sandbox) = &self.sandbox_path {
177
+ if let Ok(store) = crate::memory::MemoryStore::open_or_create(sandbox) {
178
+ let mem = store.codex_memories();
179
+ if let Ok(log) = journal.read_log() {
180
+ let _ = mem.write_rollout_summary(journal.run_id(), &log);
181
+ let _ = mem.write_raw_memories(&log);
182
+ }
183
+ }
184
+ }
145
185
  }
146
186
  }
147
187
 
@@ -155,6 +195,7 @@ impl App {
155
195
  "/tokens".to_string(),
156
196
  "/init".to_string(),
157
197
  "/docs".to_string(),
198
+ "/skills".to_string(),
158
199
  "/exit".to_string(),
159
200
  ];
160
201
  if !self.input_text.starts_with('/') {
@@ -235,6 +276,14 @@ impl App {
235
276
  timestamp: Local::now().format("%H:%M:%S").to_string(),
236
277
  });
237
278
 
279
+ self.log_run(
280
+ "user_message",
281
+ json!({
282
+ "text": text,
283
+ "is_command": text.starts_with('/'),
284
+ }),
285
+ );
286
+
238
287
  let mut should_exit = false;
239
288
  let mut is_command = false;
240
289
 
@@ -246,6 +295,7 @@ impl App {
246
295
  let cmd = parts[0][1..].to_lowercase();
247
296
  match cmd.as_str() {
248
297
  "exit" | "quit" => {
298
+ self.log_run("session_exit", json!({ "reason": "user_command" }));
249
299
  should_exit = true;
250
300
  }
251
301
  "clear" => {
@@ -276,6 +326,7 @@ impl App {
276
326
  /tokens - Show model routing and token consumption statistics\n\
277
327
  /init - Scan the sandbox directory for COBOL files\n\
278
328
  /docs - Generate Markdown documentation for all COBOL files into docs/\n\
329
+ /skills - Check which agent skills (.cobolx/skills) are actually loaded\n\
279
330
  /exit - Close the interactive console";
280
331
  self.messages.push(Message {
281
332
  sender: Sender::Cobolx,
@@ -302,6 +353,13 @@ impl App {
302
353
  ) {
303
354
  Ok((report, db_path)) => {
304
355
  self.discovered_files = report.files.clone();
356
+ self.log_run(
357
+ "index_completed",
358
+ json!({
359
+ "file_count": report.files.len(),
360
+ "db_path": db_path.to_string_lossy(),
361
+ }),
362
+ );
305
363
  if self.discovered_files.is_empty() {
306
364
  self.messages.push(Message {
307
365
  sender: Sender::Cobolx,
@@ -317,6 +375,7 @@ impl App {
317
375
  }
318
376
  }
319
377
  Err(e) => {
378
+ self.log_run("index_failed", json!({ "error": e.to_string() }));
320
379
  self.messages.push(Message {
321
380
  sender: Sender::Cobolx,
322
381
  text: format!("Error indexing sandbox: {}", e),
@@ -590,6 +649,73 @@ impl App {
590
649
  });
591
650
  }
592
651
  }
652
+ "skills" => {
653
+ if let Some(ref sandbox_path) = self.sandbox_path {
654
+ let checks = crate::agent::skills::check_all_agent_skills(sandbox_path);
655
+ let mut report = String::from(
656
+ "Agent Skills Check (.cobolx/skills):\n\
657
+ -------------------------------------\n",
658
+ );
659
+ for check in &checks {
660
+ let dir = format!(".cobolx/skills/{}", check.agent.skill_dir());
661
+ if let Some(err) = &check.error {
662
+ report.push_str(&format!(
663
+ "[{}] {} → ERROR: {}\n",
664
+ check.agent.label(),
665
+ dir,
666
+ err
667
+ ));
668
+ continue;
669
+ }
670
+ if !check.dir_exists {
671
+ report.push_str(&format!(
672
+ "[{}] {} → not configured (no directory)\n",
673
+ check.agent.label(),
674
+ dir
675
+ ));
676
+ continue;
677
+ }
678
+ if check.files.is_empty() {
679
+ report.push_str(&format!(
680
+ "[{}] {} → directory exists, no .md skill files found\n",
681
+ check.agent.label(),
682
+ dir
683
+ ));
684
+ continue;
685
+ }
686
+ let status = if check.is_active() {
687
+ "ACTIVE (loaded into system prompt)"
688
+ } else {
689
+ "INACTIVE (failed to load)"
690
+ };
691
+ report.push_str(&format!(
692
+ "[{}] {} → {}\n files: {} ({} bytes source, {} bytes injected{})\n",
693
+ check.agent.label(),
694
+ dir,
695
+ status,
696
+ check.files.len(),
697
+ check.source_bytes,
698
+ check.injected_bytes,
699
+ if check.truncated { ", TRUNCATED at 8KB cap" } else { "" }
700
+ ));
701
+ for f in &check.files {
702
+ report.push_str(&format!(" - {}\n", f));
703
+ }
704
+ }
705
+ self.messages.push(Message {
706
+ sender: Sender::Cobolx,
707
+ text: report.trim_end().to_string(),
708
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
709
+ });
710
+ } else {
711
+ self.messages.push(Message {
712
+ sender: Sender::Cobolx,
713
+ text: "No sandbox directory set. Please select a sandbox first."
714
+ .to_string(),
715
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
716
+ });
717
+ }
718
+ }
593
719
  _ => {
594
720
  self.messages.push(Message {
595
721
  sender: Sender::Cobolx,
@@ -703,6 +829,66 @@ fn trigger_chat_task(app: &mut App, tx: &tokio::sync::mpsc::UnboundedSender<Task
703
829
  false
704
830
  }
705
831
 
832
+ fn spawn_memory_consolidation(
833
+ app: &App,
834
+ tx: &tokio::sync::mpsc::UnboundedSender<TaskUpdate>,
835
+ tokens_summarized: u32,
836
+ ) {
837
+ let sandbox = app.sandbox_path.clone();
838
+ let router = Arc::clone(&app.router);
839
+ let run_log = app
840
+ .run_journal
841
+ .as_ref()
842
+ .and_then(|j| j.read_log().ok())
843
+ .unwrap_or_default();
844
+ let tx = tx.clone();
845
+
846
+ tokio::spawn(async move {
847
+ let _ = tx.send(TaskUpdate::Status(
848
+ "Consolidating memories (Codex Phase 2)...".to_string(),
849
+ ));
850
+ let result: Result<(), String> = async {
851
+ let sandbox = sandbox.ok_or("No sandbox path for memory consolidation.")?;
852
+ let store =
853
+ crate::memory::MemoryStore::open_or_create(&sandbox).map_err(|e| e.to_string())?;
854
+ let mem = store.codex_memories();
855
+ let summary = mem.read_memory_summary().map_err(|e| e.to_string())?;
856
+ let handbook = mem.read_memory_handbook().map_err(|e| e.to_string())?;
857
+ let (new_summary, new_handbook) = router
858
+ .consolidate_codex_memories(&summary, &handbook, &run_log, tokens_summarized)
859
+ .await?;
860
+ mem.write_consolidated(&new_summary, &new_handbook)
861
+ .map_err(|e| e.to_string())?;
862
+ Ok(())
863
+ }
864
+ .await;
865
+ let _ = tx.send(TaskUpdate::Status("".to_string()));
866
+ match result {
867
+ Ok(()) => {
868
+ let _ = tx.send(TaskUpdate::MemorySummarized {
869
+ ok: true,
870
+ detail: format!("~{tokens_summarized} tokens → memory_summary.md + MEMORY.md"),
871
+ });
872
+ }
873
+ Err(err) => {
874
+ let _ = tx.send(TaskUpdate::MemorySummarized {
875
+ ok: false,
876
+ detail: err,
877
+ });
878
+ }
879
+ }
880
+ });
881
+ }
882
+
883
+ fn route_label(route: &crate::agent::client::Route) -> &'static str {
884
+ match route {
885
+ crate::agent::client::Route::Light => "LIGHT",
886
+ crate::agent::client::Route::Heavy => "HEAVY",
887
+ crate::agent::client::Route::Database => "DATABASE",
888
+ crate::agent::client::Route::Filesystem => "FILESYSTEM",
889
+ }
890
+ }
891
+
706
892
  pub fn run_tui() -> Result<(), io::Error> {
707
893
  enable_raw_mode()?;
708
894
  let mut stdout = io::stdout();
@@ -718,6 +904,13 @@ pub fn run_tui() -> Result<(), io::Error> {
718
904
  while let Ok(update) = rx.try_recv() {
719
905
  match update {
720
906
  TaskUpdate::Routed(ref route, model_used) => {
907
+ app.log_run(
908
+ "route_selected",
909
+ json!({
910
+ "route": route_label(route),
911
+ "model": model_used,
912
+ }),
913
+ );
721
914
  app.active_agent = Some(model_used.to_string());
722
915
  if matches!(
723
916
  route,
@@ -769,6 +962,9 @@ pub fn run_tui() -> Result<(), io::Error> {
769
962
  }
770
963
  }
771
964
  TaskUpdate::Status(status) => {
965
+ if !status.is_empty() {
966
+ app.log_run("agent_status", json!({ "status": status }));
967
+ }
772
968
  if status.is_empty() {
773
969
  app.agent_status = None;
774
970
  } else {
@@ -776,6 +972,25 @@ pub fn run_tui() -> Result<(), io::Error> {
776
972
  }
777
973
  }
778
974
  TaskUpdate::Finished(res, model_used) => {
975
+ let payload = match &res {
976
+ Ok(Some(usage)) => json!({
977
+ "model": model_used,
978
+ "ok": true,
979
+ "prompt_tokens": usage.prompt_tokens,
980
+ "completion_tokens": usage.completion_tokens,
981
+ "total_tokens": usage.total_tokens,
982
+ }),
983
+ Ok(None) => json!({
984
+ "model": model_used,
985
+ "ok": true,
986
+ }),
987
+ Err(err) => json!({
988
+ "model": model_used,
989
+ "ok": false,
990
+ "error": err,
991
+ }),
992
+ };
993
+ app.log_run("agent_finished", payload);
779
994
  app.active_agent = None;
780
995
  app.agent_status = None;
781
996
  if let Some(msg) = app.messages.iter_mut().last() {
@@ -802,6 +1017,13 @@ pub fn run_tui() -> Result<(), io::Error> {
802
1017
  app.glm_prompt_tokens += usage.prompt_tokens;
803
1018
  app.glm_completion_tokens += usage.completion_tokens;
804
1019
  }
1020
+
1021
+ app.tokens_since_summary += usage.total_tokens;
1022
+ if app.tokens_since_summary >= TOKEN_SUMMARY_THRESHOLD {
1023
+ let tokens_batch = app.tokens_since_summary;
1024
+ app.tokens_since_summary = 0;
1025
+ spawn_memory_consolidation(&app, &tx, tokens_batch);
1026
+ }
805
1027
  }
806
1028
  Ok(None) => {
807
1029
  app.last_model = Some(model_used.to_string());
@@ -820,6 +1042,16 @@ pub fn run_tui() -> Result<(), io::Error> {
820
1042
  }
821
1043
  }
822
1044
  }
1045
+ TaskUpdate::MemorySummarized { ok, detail } => {
1046
+ app.log_run("memory_summarized", json!({ "ok": ok, "detail": detail }));
1047
+ if ok {
1048
+ app.messages.push(Message {
1049
+ sender: Sender::Cobolx,
1050
+ text: format!("Memory consolidated ({detail})."),
1051
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
1052
+ });
1053
+ }
1054
+ }
823
1055
  }
824
1056
  }
825
1057
 
@@ -928,7 +1160,12 @@ pub fn run_tui() -> Result<(), io::Error> {
928
1160
  .and_then(|p| p.parent().map(|parent| parent.to_path_buf()))
929
1161
  };
930
1162
  if let Some(path) = resolved {
1163
+ app.start_run_journal(&path);
931
1164
  app.sandbox_path = Some(path.clone());
1165
+ app.log_run(
1166
+ "sandbox_selected",
1167
+ json!({ "path": path.to_string_lossy() }),
1168
+ );
932
1169
  app.view_mode = ViewMode::Chat;
933
1170
  app.messages.push(Message {
934
1171
  sender: Sender::Cobolx,
@@ -1048,6 +1285,8 @@ pub fn run_tui() -> Result<(), io::Error> {
1048
1285
  }
1049
1286
  }
1050
1287
 
1288
+ app.finish_run_journal("completed");
1289
+
1051
1290
  disable_raw_mode()?;
1052
1291
  execute!(
1053
1292
  terminal.backend_mut(),
@@ -190,3 +190,264 @@ fn init_indexer_persists_program_copybook_and_call_edges() {
190
190
  assert_eq!(alias_layout, (8, 10));
191
191
  assert_eq!(occurs_layout, (23, 60));
192
192
  }
193
+
194
+ #[test]
195
+ fn init_indexer_persists_richer_semantic_index_data() {
196
+ let dir = tempdir().unwrap();
197
+ let copy_dir = dir.path().join("copy");
198
+ fs::create_dir_all(&copy_dir).unwrap();
199
+
200
+ File::create(dir.path().join("MAIN.cbl"))
201
+ .unwrap()
202
+ .write_all(
203
+ br#"
204
+ IDENTIFICATION DIVISION.
205
+ PROGRAM-ID. MAIN.
206
+ DATA DIVISION.
207
+ WORKING-STORAGE SECTION.
208
+ 01 WS-NEXT-PGM PIC X(8).
209
+ 01 CUSTOMER-FILE PIC X(8).
210
+ 01 CUSTOMER-RECORD PIC X(80).
211
+ COPY COMMONHDR.
212
+ COPY ERRCODES.
213
+ PROCEDURE DIVISION.
214
+ MAIN-SECTION SECTION.
215
+ MAIN-START.
216
+ OPEN INPUT CUSTOMER-FILE.
217
+ EXEC SQL
218
+ SELECT CUST_ID
219
+ INTO :WS-NEXT-PGM
220
+ FROM CUSTOMER_TABLE
221
+ WHERE STATUS = 'ER'
222
+ END-EXEC.
223
+ CALL "SUB001"
224
+ USING WS-NEXT-PGM.
225
+ CALL WS-NEXT-PGM.
226
+ PERFORM HANDLE-ERROR.
227
+ READ CUSTOMER-FILE.
228
+ HANDLE-ERROR.
229
+ EXEC CICS
230
+ LINK PROGRAM('ERRHNDL')
231
+ COMMAREA(WS-NEXT-PGM)
232
+ END-EXEC.
233
+ WRITE CUSTOMER-RECORD.
234
+ CLOSE CUSTOMER-FILE.
235
+ STOP RUN.
236
+ "#,
237
+ )
238
+ .unwrap();
239
+
240
+ File::create(dir.path().join("SUB001.cbl"))
241
+ .unwrap()
242
+ .write_all(
243
+ br#"
244
+ IDENTIFICATION DIVISION.
245
+ PROGRAM-ID. SUB001.
246
+ PROCEDURE DIVISION.
247
+ EXIT PROGRAM.
248
+ "#,
249
+ )
250
+ .unwrap();
251
+
252
+ File::create(copy_dir.join("COMMONHDR.cpy"))
253
+ .unwrap()
254
+ .write_all(
255
+ br#"
256
+ 01 COMMON-HDR.
257
+ 05 HDR-REQUEST-ID PIC X(16).
258
+ 05 HDR-DATE PIC X(8).
259
+ "#,
260
+ )
261
+ .unwrap();
262
+
263
+ File::create(copy_dir.join("ERRCODES.cpy"))
264
+ .unwrap()
265
+ .write_all(
266
+ br#"
267
+ 01 ERROR-INFO.
268
+ 05 ERR-CODE PIC X(4).
269
+ 05 ERR-MESSAGE PIC X(40).
270
+ "#,
271
+ )
272
+ .unwrap();
273
+
274
+ let mut store = rdo::memory::MemoryStore::open_or_create(dir.path()).unwrap();
275
+ rdo::cobol::indexer::index_sandbox(dir.path(), &mut store).unwrap();
276
+
277
+ let conn = store.connection();
278
+
279
+ let main_program_features: (i64, i64, i64, i64, i64, i64, i64, i64, i64, i64) = conn
280
+ .query_row(
281
+ "SELECT incoming_call_count, outgoing_call_count, static_call_count, dynamic_call_count, \
282
+ copybook_use_count, referenced_by_file_count, is_entrypoint, paragraph_count, \
283
+ external_op_count, literal_count \
284
+ FROM program_features pf \
285
+ JOIN programs p ON p.id = pf.program_id \
286
+ WHERE p.name = 'MAIN'",
287
+ [],
288
+ |row| {
289
+ Ok((
290
+ row.get(0)?,
291
+ row.get(1)?,
292
+ row.get(2)?,
293
+ row.get(3)?,
294
+ row.get(4)?,
295
+ row.get(5)?,
296
+ row.get(6)?,
297
+ row.get(7)?,
298
+ row.get(8)?,
299
+ row.get(9)?,
300
+ ))
301
+ },
302
+ )
303
+ .unwrap();
304
+
305
+ let sub_program_features: (i64, i64, i64) = conn
306
+ .query_row(
307
+ "SELECT incoming_call_count, referenced_by_file_count, is_entrypoint \
308
+ FROM program_features pf \
309
+ JOIN programs p ON p.id = pf.program_id \
310
+ WHERE p.name = 'SUB001'",
311
+ [],
312
+ |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
313
+ )
314
+ .unwrap();
315
+
316
+ let code_blocks: Vec<(String, String, i64)> = {
317
+ let mut stmt = conn
318
+ .prepare(
319
+ "SELECT cb.name, cb.kind, cb.statement_count \
320
+ FROM code_blocks cb \
321
+ JOIN programs p ON p.id = cb.program_id \
322
+ WHERE p.name = 'MAIN' \
323
+ ORDER BY sequence_no",
324
+ )
325
+ .unwrap();
326
+ stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
327
+ .unwrap()
328
+ .collect::<Result<Vec<_>, _>>()
329
+ .unwrap()
330
+ };
331
+
332
+ let external_ops: Vec<(String, String, Option<String>)> = {
333
+ let mut stmt = conn
334
+ .prepare(
335
+ "SELECT kind, verb, target \
336
+ FROM external_ops eo \
337
+ JOIN programs p ON p.id = eo.program_id \
338
+ WHERE p.name = 'MAIN' \
339
+ ORDER BY eo.id",
340
+ )
341
+ .unwrap();
342
+ stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
343
+ .unwrap()
344
+ .collect::<Result<Vec<_>, _>>()
345
+ .unwrap()
346
+ };
347
+
348
+ let identifiers: Vec<(String, String, i64)> = {
349
+ let mut stmt = conn
350
+ .prepare(
351
+ "SELECT kind, value, occurrences \
352
+ FROM identifiers i \
353
+ JOIN programs p ON p.id = i.program_id \
354
+ WHERE p.name = 'MAIN' \
355
+ ORDER BY kind, value",
356
+ )
357
+ .unwrap();
358
+ stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
359
+ .unwrap()
360
+ .collect::<Result<Vec<_>, _>>()
361
+ .unwrap()
362
+ };
363
+
364
+ let literals: Vec<(String, String, i64)> = {
365
+ let mut stmt = conn
366
+ .prepare(
367
+ "SELECT kind, value, occurrences \
368
+ FROM literals l \
369
+ JOIN programs p ON p.id = l.program_id \
370
+ WHERE p.name = 'MAIN' \
371
+ ORDER BY kind, value",
372
+ )
373
+ .unwrap();
374
+ stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
375
+ .unwrap()
376
+ .collect::<Result<Vec<_>, _>>()
377
+ .unwrap()
378
+ };
379
+
380
+ let copybook_features: Vec<(String, i64, i64, i64, i64, i64)> = {
381
+ let mut stmt = conn
382
+ .prepare(
383
+ "SELECT cf.copybook_name, cf.used_by_program_count, cf.used_by_file_count, \
384
+ cf.data_item_count, cf.contains_header_fields, cf.contains_error_fields \
385
+ FROM copybook_features cf \
386
+ ORDER BY cf.copybook_name",
387
+ )
388
+ .unwrap();
389
+ stmt.query_map([], |row| {
390
+ Ok((
391
+ row.get(0)?,
392
+ row.get(1)?,
393
+ row.get(2)?,
394
+ row.get(3)?,
395
+ row.get(4)?,
396
+ row.get(5)?,
397
+ ))
398
+ })
399
+ .unwrap()
400
+ .collect::<Result<Vec<_>, _>>()
401
+ .unwrap()
402
+ };
403
+
404
+ assert_eq!(main_program_features, (0, 2, 1, 1, 2, 0, 1, 2, 8, 3));
405
+ assert_eq!(sub_program_features, (1, 1, 0));
406
+ assert_eq!(
407
+ code_blocks,
408
+ vec![
409
+ ("MAIN-SECTION".to_string(), "section".to_string(), 0),
410
+ ("MAIN-START".to_string(), "paragraph".to_string(), 6),
411
+ ("HANDLE-ERROR".to_string(), "paragraph".to_string(), 4),
412
+ ]
413
+ );
414
+ assert!(external_ops.contains(&(
415
+ "file_io".to_string(),
416
+ "OPEN".to_string(),
417
+ Some("CUSTOMER-FILE".to_string())
418
+ )));
419
+ assert!(external_ops.contains(&(
420
+ "exec_sql".to_string(),
421
+ "SELECT".to_string(),
422
+ Some("CUSTOMER_TABLE".to_string())
423
+ )));
424
+ assert!(external_ops.contains(&(
425
+ "call_literal".to_string(),
426
+ "CALL".to_string(),
427
+ Some("SUB001".to_string())
428
+ )));
429
+ assert!(external_ops.contains(&(
430
+ "call_identifier".to_string(),
431
+ "CALL".to_string(),
432
+ Some("WS-NEXT-PGM".to_string())
433
+ )));
434
+ assert!(external_ops.contains(&(
435
+ "exec_cics".to_string(),
436
+ "LINK".to_string(),
437
+ Some("ERRHNDL".to_string())
438
+ )));
439
+ assert!(identifiers.contains(&("sql_table".to_string(), "CUSTOMER_TABLE".to_string(), 1)));
440
+ assert!(identifiers.contains(&("file_name".to_string(), "CUSTOMER-FILE".to_string(), 3)));
441
+ assert!(identifiers.contains(&("paragraph_name".to_string(), "HANDLE-ERROR".to_string(), 1)));
442
+ assert!(identifiers.contains(&("data_name".to_string(), "ERR-CODE".to_string(), 1)));
443
+ assert!(literals.contains(&("call_target".to_string(), "SUB001".to_string(), 1)));
444
+ assert!(literals.contains(&("exec_cics_program".to_string(), "ERRHNDL".to_string(), 1)));
445
+ assert!(literals.contains(&("string_literal".to_string(), "ER".to_string(), 1)));
446
+ assert_eq!(
447
+ copybook_features,
448
+ vec![
449
+ ("COMMONHDR".to_string(), 1, 1, 3, 1, 0),
450
+ ("ERRCODES".to_string(), 1, 1, 3, 0, 1),
451
+ ]
452
+ );
453
+ }