//! In-place mutations of reflection/qa.ncl. //! //! Mirrors backlog_ncl.rs — line-level surgery on a predictable Nickel //! structure. The QA store has a single `entries` array of `QaEntry` records. use std::path::Path; /// Append a new Q&A entry to reflection/qa.ncl. /// /// Returns the generated id (`qa-NNN`). pub fn add_entry( path: &Path, question: &str, answer: &str, actor: &str, created_at: &str, tags: &[String], related: &[String], ) -> anyhow::Result { let content = std::fs::read_to_string(path)?; let next_id = next_entry_id(&content); let block = format!( r#" {{ id = "{id}", question = "{question}", answer = "{answer}", actor = "{actor}", created_at = "{created_at}", tags = {tags}, related = {related}, verified = false, }}, "#, id = next_id, question = escape_ncl(question), answer = escape_ncl(answer), actor = escape_ncl(actor), created_at = escape_ncl(created_at), tags = ncl_string_array(tags), related = ncl_string_array(related), ); let updated = insert_before_entries_close(&content, &block)?; std::fs::write(path, updated)?; Ok(next_id) } /// Update `question` and `answer` fields for the entry with `id`. pub fn update_entry(path: &Path, id: &str, question: &str, answer: &str) -> anyhow::Result<()> { let content = std::fs::read_to_string(path)?; let updated = mutate_entry_fields( &content, id, &[ ("question", &format!("\"{}\"", escape_ncl(question))), ("answer", &format!("\"{}\"", escape_ncl(answer))), ], ); std::fs::write(path, updated)?; Ok(()) } /// Remove the entry block with `id` from the entries array. pub fn remove_entry(path: &Path, id: &str) -> anyhow::Result<()> { let content = std::fs::read_to_string(path)?; let updated = delete_entry_block(&content, id)?; std::fs::write(path, updated)?; Ok(()) } // ── helpers ────────────────────────────────────────────────────────────────── /// Replace field values inside the entry block identified by `id`. /// /// For each `(field, new_value)`, finds the `field = ...,` line inside the /// block and substitutes the value in-place. fn mutate_entry_fields(content: &str, id: &str, fields: &[(&str, &str)]) -> String { let id_needle = format!("\"{}\"", id); let mut in_block = false; let mut result: Vec = Vec::with_capacity(content.lines().count() + 1); for line in content.lines() { if !in_block { if line.contains(&id_needle) && line.contains('=') { in_block = true; } result.push(line.to_string()); continue; } let trimmed = line.trim_start(); let replacement = fields.iter().find_map(|(field, new_val)| { if !trimmed.starts_with(field) { return None; } let eq_pos = trimmed.find('=')?; let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); let before_eq = trimmed[..eq_pos].trim_end(); Some(format!("{}{} = {},", indent, before_eq, new_val)) }); result.push(replacement.unwrap_or_else(|| line.to_string())); if trimmed == "}," { in_block = false; } } result.join("\n") } /// Remove the block containing `id = "qa-NNN"`. /// /// Scans line by line, tracking ` {` opens and ` },` closes. Removes the /// entire block (from the ` {` through ` },` inclusive) that contains the /// id needle. fn delete_entry_block(content: &str, id: &str) -> anyhow::Result { let id_needle = format!("\"{}\"", id); let lines: Vec<&str> = content.lines().collect(); let n = lines.len(); // Find the line index containing the id field. let id_line = lines .iter() .position(|l| l.contains(&id_needle) && l.contains('=')) .ok_or_else(|| anyhow::anyhow!("entry id {} not found in qa.ncl", id))?; // Scan backward from id_line to find ` {` (block open — exactly 4 spaces + // `{`). let block_start = (0..=id_line) .rev() .find(|&i| lines[i].trim() == "{") .ok_or_else(|| anyhow::anyhow!("could not find block open for entry {}", id))?; // Scan forward from id_line to find ` },` (block close — trim == `},`). let block_end = (id_line..n) .find(|&i| lines[i].trim() == "},") .ok_or_else(|| anyhow::anyhow!("could not find block close for entry {}", id))?; // Reconstruct without [block_start..=block_end]. let mut result = Vec::with_capacity(n - (block_end - block_start + 1)); for (i, line) in lines.iter().enumerate() { if i < block_start || i > block_end { result.push(*line); } } Ok(result.join("\n")) } /// Find the highest `qa-NNN` id and return `qa-(NNN+1)` zero-padded to 3 /// digits. fn next_entry_id(content: &str) -> String { let max = content .lines() .filter_map(|line| { let t = line.trim(); let rest = t.strip_prefix("id")?; let val = rest.split('"').nth(1)?; let num_str = val.strip_prefix("qa-")?; num_str.parse::().ok() }) .max() .unwrap_or(0); format!("qa-{:03}", max + 1) } /// Insert `block` before the closing ` ],` of the entries array. fn insert_before_entries_close(content: &str, block: &str) -> anyhow::Result { let needle = " ],"; let pos = content.find(needle).ok_or_else(|| { anyhow::anyhow!("could not locate entries array closing ` ],` in qa.ncl") })?; let mut result = String::with_capacity(content.len() + block.len()); result.push_str(&content[..pos]); result.push_str(block); result.push_str(&content[pos..]); Ok(result) } /// Format a `&[String]` as a Nickel array literal: `["a", "b"]`. fn ncl_string_array(items: &[String]) -> String { if items.is_empty() { return "[]".to_string(); } let inner: Vec = items .iter() .map(|s| format!("\"{}\"", escape_ncl(s))) .collect(); format!("[{}]", inner.join(", ")) } /// Minimal escaping for string values embedded in Nickel double-quoted strings. fn escape_ncl(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") } #[cfg(test)] mod tests { use super::*; const SAMPLE: &str = concat!( "let s = import \"qa\" in\n", "{\n", " entries = [\n", " {\n", " id = \"qa-001\",\n", " question = \"What is X?\",\n", " answer = \"It is Y.\",\n", " actor = \"human\",\n", " created_at = \"2026-03-12\",\n", " tags = [],\n", " related = [],\n", " verified = false,\n", " },\n", " {\n", " id = \"qa-002\",\n", " question = \"Second?\",\n", " answer = \"Yes.\",\n", " actor = \"agent\",\n", " created_at = \"2026-03-12\",\n", " tags = [],\n", " related = [],\n", " verified = false,\n", " },\n", " ],\n", "} | s.QaStore\n", ); #[test] fn next_id_empty() { assert_eq!(next_entry_id(""), "qa-001"); } #[test] fn next_id_increments() { let content = r#"id = "qa-005","#; assert_eq!(next_entry_id(content), "qa-006"); } #[test] fn array_empty() { assert_eq!(ncl_string_array(&[]), "[]"); } #[test] fn array_values() { let v = vec!["a".to_string(), "b".to_string()]; assert_eq!(ncl_string_array(&v), r#"["a", "b"]"#); } #[test] fn insert_before_close() { let content = "let s = import \"qa\" in\n{\n entries = [\n ],\n} | s.QaStore\n"; let block = " { id = \"qa-001\" },\n"; let result = insert_before_entries_close(content, block).unwrap(); assert!(result.contains("{ id = \"qa-001\" }")); assert!(result.contains(" ],")); } #[test] fn update_answer() { let updated = mutate_entry_fields(SAMPLE, "qa-001", &[("answer", "\"New answer.\"")]); assert!(updated.contains("\"New answer.\""), "answer not updated"); assert!( updated.contains("\"Second?\""), "qa-002 should be untouched" ); } #[test] fn delete_first_entry() { let updated = delete_entry_block(SAMPLE, "qa-001").unwrap(); assert!(!updated.contains("qa-001"), "qa-001 should be removed"); assert!(updated.contains("qa-002"), "qa-002 should remain"); } #[test] fn delete_second_entry() { let updated = delete_entry_block(SAMPLE, "qa-002").unwrap(); assert!(updated.contains("qa-001"), "qa-001 should remain"); assert!(!updated.contains("qa-002"), "qa-002 should be removed"); } #[test] fn delete_missing_id_errors() { assert!(delete_entry_block(SAMPLE, "qa-999").is_err()); } }