Jesús Pérez 2d87d60bb5
Some checks failed
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
chore: add src code
2026-03-13 00:18:14 +00:00

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());
}
}