use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs::{self, File}; use std::io::Write; #[cfg(test)] use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use crate::registry::ActionDefinition; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ActionDictionaryRecord { pub action_id: String, pub aliases: Vec, pub description: String, pub intent_examples: Vec, pub payload_schema: serde_json::Value, pub executable: crate::registry::ExecutableDefinition, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ActionIndex { pub schema_version: String, pub actions: BTreeMap, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct DictionarySummary { pub action_count: usize, pub jsonl_path: PathBuf, pub index_path: PathBuf, } pub fn default_dictionary_dir() -> PathBuf { PathBuf::from("dictionary") } pub fn generate_dictionary_files( actions: &[ActionDefinition], out_dir: &Path, ) -> Result { fs::create_dir_all(out_dir) .with_context(|| format!("creating dictionary directory {}", out_dir.display()))?; let jsonl_path = out_dir.join("actions.jsonl"); let index_path = out_dir.join("actions.index.json"); let records = actions .iter() .cloned() .map(ActionDictionaryRecord::from) .collect::>(); let mut jsonl = File::create(&jsonl_path).with_context(|| format!("creating {}", jsonl_path.display()))?; for record in &records { serde_json::to_writer(&mut jsonl, record)?; jsonl.write_all(b"\n")?; } let index = ActionIndex { schema_version: "1.0".to_string(), actions: records .into_iter() .map(|record| (record.action_id.clone(), record)) .collect(), }; let pretty = serde_json::to_string_pretty(&index)?; fs::write(&index_path, format!("{pretty}\n")) .with_context(|| format!("writing {}", index_path.display()))?; Ok(DictionarySummary { action_count: index.actions.len(), jsonl_path, index_path, }) } pub fn load_action_index(dictionary_dir: &Path) -> Result { let path = dictionary_dir.join("actions.index.json"); let raw = fs::read_to_string(&path).with_context(|| { format!( "reading {}; run `cargo run -- dictionary generate` first", path.display() ) })?; let index = serde_json::from_str::(&raw) .with_context(|| format!("parsing {}", path.display()))?; Ok(index) } #[cfg(test)] fn read_jsonl_records(path: &Path) -> Result> { let file = File::open(path).with_context(|| format!("opening {}", path.display()))?; let reader = BufReader::new(file); let mut records = Vec::new(); for line in reader.lines() { let line = line?; if line.trim().is_empty() { continue; } records.push(serde_json::from_str::(&line)?); } Ok(records) } impl From for ActionDictionaryRecord { fn from(action: ActionDefinition) -> Self { Self { action_id: action.action_id, aliases: action.aliases, description: action.description, intent_examples: action.intent_examples, payload_schema: action.payload_schema, executable: action.executable, } } } #[cfg(test)] mod tests { use super::*; use crate::registry::registered_actions; #[test] fn generates_jsonl_and_index() { let temp = tempfile::tempdir().expect("temp dir"); let summary = generate_dictionary_files(®istered_actions(), temp.path()).unwrap(); assert_eq!(summary.action_count, 1); assert!(summary.jsonl_path.exists()); assert!(summary.index_path.exists()); let records = read_jsonl_records(&summary.jsonl_path).unwrap(); assert_eq!(records.len(), 1); assert_eq!(records[0].action_id, "logger"); let index = load_action_index(temp.path()).unwrap(); assert!(index.actions.contains_key("logger")); assert!(!index.actions.contains_key("log")); } }