use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use duckdb::types::Value as DuckValue;
use uuid::Uuid;
use crate::inner_poet::fast::{Finding, Severity};
use crate::storage::engine::StorageEngine;
use crate::world::proposals::now_secs;
const INIT_SQL: &str = "
CREATE TABLE IF NOT EXISTS poet_findings (
id TEXT NOT NULL PRIMARY KEY,
paragraph_id TEXT NOT NULL,
severity TEXT NOT NULL,
kind TEXT NOT NULL,
line BIGINT NOT NULL,
message TEXT NOT NULL,
emitted_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pf_para ON poet_findings(paragraph_id);
-- A suppressed finding: the author asked not to be told about this
-- (paragraph, finding-key) again. `finding_key` is severity|kind|line — a
-- stable fingerprint of the complaint, independent of the exact message.
CREATE TABLE IF NOT EXISTS poet_suppressions (
paragraph_id TEXT NOT NULL,
finding_key TEXT NOT NULL,
suppressed_at BIGINT NOT NULL,
PRIMARY KEY (paragraph_id, finding_key)
);
";
fn text(v: Option<&DuckValue>) -> String {
match v {
Some(DuckValue::Text(s)) => s.clone(),
_ => String::new(),
}
}
fn int(v: Option<&DuckValue>) -> i64 {
match v {
Some(DuckValue::BigInt(i)) => *i,
Some(DuckValue::Int(i)) => *i as i64,
Some(DuckValue::HugeInt(i)) => *i as i64,
_ => 0,
}
}
pub fn finding_key(severity: &str, kind: &str, line: usize) -> String {
format!("{severity}|{kind}|L{line}")
}
fn severity_str(s: Severity) -> &'static str {
match s {
Severity::Praise => "praise",
Severity::Note => "note",
Severity::Concern => "concern",
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct StoredPoetFinding {
pub paragraph_id: Uuid,
pub severity: String,
pub kind: String,
pub line: usize,
pub message: String,
}
impl StoredPoetFinding {
pub fn key(&self) -> String {
finding_key(&self.severity, &self.kind, self.line)
}
}
#[derive(Clone)]
pub struct InnerPoetStore {
engine: Arc<StorageEngine>,
}
impl InnerPoetStore {
pub fn open(path: &Path) -> Result<Self> {
Ok(Self { engine: Arc::new(StorageEngine::new(path, INIT_SQL, 2)?) })
}
pub fn open_for_project(project_root: &Path) -> Result<Self> {
Self::open(&project_root.join("inner_poet.db"))
}
pub fn replace_findings(&self, paragraph_id: Uuid, findings: &[Finding]) -> Result<()> {
let pid = paragraph_id.to_string();
let now = now_secs();
self.engine.transaction(|conn| {
conn.execute(
"DELETE FROM poet_findings WHERE paragraph_id = ?",
duckdb::params![pid],
)?;
for f in findings {
conn.execute(
"INSERT INTO poet_findings \
(id, paragraph_id, severity, kind, line, message, emitted_at) \
VALUES (?,?,?,?,?,?,?)",
duckdb::params![
Uuid::new_v4().to_string(),
pid,
severity_str(f.severity),
f.kind.to_string(),
f.line as i64,
f.message,
now,
],
)?;
}
Ok(())
})
}
fn rows_to_findings(&self, sql: &str, args: &[&dyn duckdb::ToSql]) -> Result<Vec<StoredPoetFinding>> {
let rows = self.engine.select_all_with(sql, args)?;
Ok(rows
.iter()
.filter_map(|r| {
Some(StoredPoetFinding {
paragraph_id: Uuid::parse_str(&text(r.get(0))).ok()?,
severity: text(r.get(1)),
kind: text(r.get(2)),
line: int(r.get(3)).max(0) as usize,
message: text(r.get(4)),
})
})
.collect())
}
pub fn findings_for(&self, paragraph_id: Uuid) -> Result<Vec<StoredPoetFinding>> {
self.rows_to_findings(
"SELECT paragraph_id, severity, kind, line, message FROM poet_findings \
WHERE paragraph_id = ? ORDER BY line, id",
&[¶graph_id.to_string()],
)
}
#[allow(dead_code)]
pub fn all_findings(&self) -> Result<Vec<StoredPoetFinding>> {
self.rows_to_findings(
"SELECT paragraph_id, severity, kind, line, message FROM poet_findings \
ORDER BY emitted_at DESC, line, id",
&[],
)
}
pub fn suppress(&self, paragraph_id: Uuid, key: &str) -> Result<()> {
self.engine.execute_with(
"INSERT OR REPLACE INTO poet_suppressions (paragraph_id, finding_key, suppressed_at) \
VALUES (?,?,?)",
&[¶graph_id.to_string(), &key.to_string(), &now_secs()],
)
}
#[allow(dead_code)]
pub fn unsuppress(&self, paragraph_id: Uuid, key: &str) -> Result<()> {
self.engine.execute_with(
"DELETE FROM poet_suppressions WHERE paragraph_id = ? AND finding_key = ?",
&[¶graph_id.to_string(), &key.to_string()],
)
}
pub fn suppressions_for(&self, paragraph_id: Uuid) -> Result<Vec<String>> {
let rows = self.engine.select_all_with(
"SELECT finding_key FROM poet_suppressions WHERE paragraph_id = ? ORDER BY finding_key",
&[¶graph_id.to_string()],
)?;
Ok(rows.iter().map(|r| text(r.first())).collect())
}
#[allow(dead_code)]
pub fn is_suppressed(&self, paragraph_id: Uuid, key: &str) -> Result<bool> {
Ok(self.suppressions_for(paragraph_id)?.iter().any(|k| k == key))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inner_poet::fast::Finding;
fn store() -> (InnerPoetStore, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
(InnerPoetStore::open(&dir.path().join("inner_poet.db")).unwrap(), dir)
}
fn finding(sev: Severity, kind: &'static str, line: usize, msg: &str) -> Finding {
Finding { severity: sev, kind, line, message: msg.to_string() }
}
#[test]
fn replace_findings_is_last_write_wins() {
let (s, _d) = store();
let p = Uuid::new_v4();
s.replace_findings(p, &[
finding(Severity::Note, "Metre", 1, "short"),
finding(Severity::Concern, "Rhyme", 2, "no rhyme"),
]).unwrap();
assert_eq!(s.findings_for(p).unwrap().len(), 2);
s.replace_findings(p, &[finding(Severity::Praise, "Metre", 1, "clean")]).unwrap();
let got = s.findings_for(p).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].severity, "praise");
assert_eq!(got[0].kind, "Metre");
assert_eq!(got[0].line, 1);
}
#[test]
fn suppression_round_trips_and_keys_are_stable() {
let (s, _d) = store();
let p = Uuid::new_v4();
let key = finding_key("concern", "Rhyme", 2);
assert!(!s.is_suppressed(p, &key).unwrap());
s.suppress(p, &key).unwrap();
s.suppress(p, &key).unwrap(); assert!(s.is_suppressed(p, &key).unwrap());
assert_eq!(s.suppressions_for(p).unwrap(), vec![key.clone()]);
s.replace_findings(p, &[finding(Severity::Concern, "Rhyme", 2, "no rhyme")]).unwrap();
assert_eq!(s.findings_for(p).unwrap()[0].key(), key);
s.unsuppress(p, &key).unwrap();
assert!(!s.is_suppressed(p, &key).unwrap());
}
}