289 lines
9.2 KiB
Rust
289 lines
9.2 KiB
Rust
//! 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<String> {
|
|
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<String> = 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<String> {
|
|
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::<u32>().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<String> {
|
|
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<String> = 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());
|
|
}
|
|
}
|