use std::fs::{self, File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::PathBuf;
use terraphim_automata::matcher::find_matches;
#[cfg(test)]
use terraphim_types::procedure::ProcedureConfidence;
use terraphim_types::{
NormalizedTerm, NormalizedTermValue, Thesaurus, procedure::CapturedProcedure,
};
pub struct ProcedureStore {
store_path: PathBuf,
}
impl ProcedureStore {
pub fn new(store_path: PathBuf) -> Self {
Self { store_path }
}
#[allow(dead_code)]
pub fn default_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("terraphim")
.join("learnings")
.join("procedures.jsonl")
}
fn ensure_dir_exists(&self) -> io::Result<()> {
if let Some(parent) = self.store_path.parent() {
fs::create_dir_all(parent)?;
}
Ok(())
}
pub fn save(&self, procedure: &CapturedProcedure) -> io::Result<()> {
self.ensure_dir_exists()?;
let mut procedures = self.load_all()?;
let existing_index = procedures.iter().position(|p| p.id == procedure.id);
if let Some(index) = existing_index {
procedures[index] = procedure.clone();
} else {
procedures.push(procedure.clone());
}
self.write_all(&procedures)
}
pub fn save_with_dedup(
&self,
mut procedure: CapturedProcedure,
) -> io::Result<CapturedProcedure> {
self.ensure_dir_exists()?;
let existing_procedures = self.load_all()?;
let mut thesaurus = Thesaurus::new("procedure_titles".to_string());
for (idx, existing) in existing_procedures.iter().enumerate() {
let normalized_title = existing.title.to_lowercase();
let term = NormalizedTerm::new(idx as u64, NormalizedTermValue::from(normalized_title));
thesaurus.insert(
NormalizedTermValue::from(existing.title.to_lowercase()),
term,
);
}
let matches = find_matches(&procedure.title.to_lowercase(), thesaurus, false)
.map_err(io::Error::other)?;
let mut merged = false;
let mut merged_procedure_id = None;
for matched in matches {
if let Some(existing) = existing_procedures
.iter()
.find(|p| p.title.to_lowercase() == matched.term.to_lowercase())
{
if existing.confidence.is_high_confidence() {
log::info!(
"Found similar procedure '{}' with high confidence ({}), merging steps",
existing.title,
existing.confidence.score
);
procedure.merge_steps(existing);
merged = true;
merged_procedure_id = Some(existing.id.clone());
break;
}
}
}
if merged {
if let Some(existing_id) = merged_procedure_id {
procedure.id = existing_id;
}
}
self.save(&procedure)?;
Ok(procedure)
}
pub fn load_all(&self) -> io::Result<Vec<CapturedProcedure>> {
if !self.store_path.exists() {
return Ok(Vec::new());
}
let file = File::open(&self.store_path)?;
let reader = BufReader::new(file);
let mut procedures = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<CapturedProcedure>(&line) {
Ok(procedure) => procedures.push(procedure),
Err(e) => {
log::warn!("Failed to parse procedure from JSONL: {}", e);
continue;
}
}
}
Ok(procedures)
}
fn write_all(&self, procedures: &[CapturedProcedure]) -> io::Result<()> {
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&self.store_path)?;
for procedure in procedures {
let json = serde_json::to_string(procedure)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
writeln!(file, "{}", json)?;
}
file.flush()?;
Ok(())
}
pub fn find_by_title(&self, query: &str) -> io::Result<Vec<CapturedProcedure>> {
let all = self.load_all()?;
let query_lower = query.to_lowercase();
let filtered: Vec<_> = all
.into_iter()
.filter(|p| {
p.title.to_lowercase().contains(&query_lower)
|| p.description.to_lowercase().contains(&query_lower)
})
.collect();
Ok(filtered)
}
pub fn find_by_id(&self, id: &str) -> io::Result<Option<CapturedProcedure>> {
let all = self.load_all()?;
Ok(all.into_iter().find(|p| p.id == id))
}
pub fn update_confidence(&self, id: &str, success: bool) -> io::Result<()> {
let mut procedures = self.load_all()?;
if let Some(procedure) = procedures.iter_mut().find(|p| p.id == id) {
if success {
procedure.record_success();
} else {
procedure.record_failure();
}
self.write_all(&procedures)?;
} else {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Procedure with ID '{}' not found", id),
));
}
Ok(())
}
pub fn delete(&self, id: &str) -> io::Result<bool> {
let mut procedures = self.load_all()?;
let original_len = procedures.len();
procedures.retain(|p| p.id != id);
if procedures.len() != original_len {
self.write_all(&procedures)?;
Ok(true)
} else {
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use terraphim_types::procedure::ProcedureStep;
fn create_test_store() -> (TempDir, ProcedureStore) {
let temp_dir = TempDir::new().unwrap();
let store_path = temp_dir.path().join("procedures.jsonl");
let store = ProcedureStore::new(store_path);
(temp_dir, store)
}
fn create_test_procedure(id: &str, title: &str) -> CapturedProcedure {
let mut procedure = CapturedProcedure::new(
id.to_string(),
title.to_string(),
format!("Description for {}", title),
);
procedure.add_step(ProcedureStep {
ordinal: 1,
command: "echo test".to_string(),
precondition: None,
postcondition: None,
working_dir: None,
privileged: false,
tags: vec!["test".to_string()],
});
procedure
}
#[test]
fn test_procedure_store_save_and_load() {
let (_temp_dir, store) = create_test_store();
let procedure = create_test_procedure("test-1", "Test Procedure");
store.save(&procedure).unwrap();
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].id, "test-1");
assert_eq!(loaded[0].title, "Test Procedure");
}
#[test]
fn test_procedure_store_find_by_title() {
let (_temp_dir, store) = create_test_store();
let proc1 = create_test_procedure("test-1", "Install Rust");
let proc2 = create_test_procedure("test-2", "Install Node.js");
let proc3 = create_test_procedure("test-3", "Deploy Application");
store.save(&proc1).unwrap();
store.save(&proc2).unwrap();
store.save(&proc3).unwrap();
let results = store.find_by_title("Install").unwrap();
assert_eq!(results.len(), 2);
assert!(results.iter().any(|p| p.title == "Install Rust"));
assert!(results.iter().any(|p| p.title == "Install Node.js"));
}
#[test]
fn test_procedure_store_update_confidence() {
let (_temp_dir, store) = create_test_store();
let mut procedure = create_test_procedure("test-1", "Test Procedure");
procedure.confidence = ProcedureConfidence::new();
store.save(&procedure).unwrap();
store.update_confidence("test-1", true).unwrap();
store.update_confidence("test-1", true).unwrap();
store.update_confidence("test-1", false).unwrap();
let loaded = store.load_all().unwrap();
assert_eq!(loaded[0].confidence.success_count, 2);
assert_eq!(loaded[0].confidence.failure_count, 1);
assert_eq!(loaded[0].confidence.score, 2.0 / 3.0);
}
#[test]
fn test_procedure_store_update_confidence_not_found() {
let (_temp_dir, store) = create_test_store();
let result = store.update_confidence("nonexistent", true);
assert!(result.is_err());
assert!(result.unwrap_err().kind() == io::ErrorKind::NotFound);
}
#[test]
fn test_dedup_matching_titles() {
let (_temp_dir, store) = create_test_store();
let mut existing_proc = create_test_procedure("existing-id", "Rust Install");
for _ in 0..10 {
existing_proc.record_success();
}
existing_proc.record_failure();
assert!(existing_proc.confidence.is_high_confidence());
existing_proc.add_step(ProcedureStep {
ordinal: 2,
command: "rustc --version".to_string(),
precondition: None,
postcondition: None,
working_dir: None,
privileged: false,
tags: vec![],
});
store.save(&existing_proc).unwrap();
let mut new_proc = create_test_procedure("new-id", "Rust Install Guide");
new_proc.add_step(ProcedureStep {
ordinal: 1,
command: "curl https://sh.rustup.rs | sh".to_string(),
precondition: None,
postcondition: None,
working_dir: None,
privileged: false,
tags: vec![],
});
let saved = store.save_with_dedup(new_proc).unwrap();
assert_eq!(
saved.step_count(),
3,
"Expected 3 steps after merge: echo test, curl, rustc"
);
let all = store.load_all().unwrap();
assert_eq!(all.len(), 1, "Should have only 1 procedure after merge");
assert_eq!(
all[0].step_count(),
3,
"Saved procedure should have 3 steps"
);
}
#[test]
fn test_dedup_no_match_for_different_titles() {
let (_temp_dir, store) = create_test_store();
let mut existing_proc = create_test_procedure("existing-id", "Install Rust");
existing_proc.confidence.success_count = 10;
existing_proc.confidence.failure_count = 0;
existing_proc.confidence.score = 1.0;
store.save(&existing_proc).unwrap();
let new_proc = create_test_procedure("new-id", "Deploy to Kubernetes");
let saved = store.save_with_dedup(new_proc).unwrap();
assert_eq!(saved.id, "new-id");
let all = store.load_all().unwrap();
assert_eq!(all.len(), 2);
}
#[test]
fn test_procedure_store_delete() {
let (_temp_dir, store) = create_test_store();
let proc1 = create_test_procedure("test-1", "Procedure 1");
let proc2 = create_test_procedure("test-2", "Procedure 2");
store.save(&proc1).unwrap();
store.save(&proc2).unwrap();
let deleted = store.delete("test-1").unwrap();
assert!(deleted);
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].id, "test-2");
let deleted_again = store.delete("test-1").unwrap();
assert!(!deleted_again);
}
#[test]
fn test_procedure_store_find_by_id() {
let (_temp_dir, store) = create_test_store();
let proc1 = create_test_procedure("test-1", "Procedure 1");
store.save(&proc1).unwrap();
let found = store.find_by_id("test-1").unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().title, "Procedure 1");
let not_found = store.find_by_id("nonexistent").unwrap();
assert!(not_found.is_none());
}
}