use std::collections::BTreeMap;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::project::ProjectLayout;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct SourceRecord {
pub origin: String,
#[serde(default)]
pub detail: String,
#[serde(default)]
pub query: String,
#[serde(default)]
pub thread: String,
pub created_at: String,
}
impl SourceRecord {
pub(super) fn new(origin: &str, detail: &str, query: &str, thread: &str, now: String) -> SourceRecord {
SourceRecord {
origin: origin.to_string(),
detail: detail.to_string(),
query: query.to_string(),
thread: thread.to_string(),
created_at: now,
}
}
pub(super) fn summary(&self) -> String {
match self.origin.as_str() {
"web" if !self.detail.is_empty() => format!("web: {}", self.detail),
"document" if !self.detail.is_empty() => format!("document: {}", self.detail),
"model" if !self.query.is_empty() => format!("model · from query: {}", self.query),
"promoted" if !self.detail.is_empty() => format!("promoted from note: {}", self.detail),
"manual" => "manual entry".to_string(),
other => other.to_string(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(super) struct Provenance {
#[serde(default)]
pub facts: BTreeMap<String, SourceRecord>,
}
impl Provenance {
fn path(layout: &ProjectLayout) -> std::path::PathBuf {
layout.root.join(".inkhaven").join("fact-sources.json")
}
pub(super) fn load(layout: &ProjectLayout) -> Provenance {
let p = Provenance::path(layout);
match std::fs::read_to_string(p) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_default(),
Err(_) => Provenance::default(),
}
}
fn save(&self, layout: &ProjectLayout) -> Result<()> {
let dir = layout.root.join(".inkhaven");
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let json = serde_json::to_string_pretty(self).context("serialise provenance")?;
crate::io_atomic::write(&Provenance::path(layout), json.as_bytes())
.context("write fact-sources.json")?;
Ok(())
}
pub(super) fn record(layout: &ProjectLayout, node_id: &str, rec: SourceRecord) {
let mut prov = Provenance::load(layout);
prov.facts.insert(node_id.to_string(), rec);
let _ = prov.save(layout);
}
pub(super) fn for_node(&self, node_id: &str) -> Option<&SourceRecord> {
self.facts.get(node_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_layout(tag: &str) -> ProjectLayout {
let dir = std::env::temp_dir().join(format!("resrch-prov-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
ProjectLayout::new(dir)
}
#[test]
fn record_and_load_roundtrip() {
let layout = tmp_layout("roundtrip");
Provenance::record(
&layout,
"node-1",
SourceRecord::new("model", "", "why is the sky green?", "rome", "2026-07-01T10:00:00Z".into()),
);
let prov = Provenance::load(&layout);
let rec = prov.for_node("node-1").unwrap();
assert_eq!(rec.origin, "model");
assert_eq!(rec.query, "why is the sky green?");
assert!(rec.summary().contains("from query"));
assert!(prov.for_node("missing").is_none());
}
#[test]
fn summaries_by_origin() {
let m = SourceRecord::new("manual", "", "", "t", "now".into());
assert_eq!(m.summary(), "manual entry");
let p = SourceRecord::new("promoted", "notes/rome/idea", "", "t", "now".into());
assert!(p.summary().contains("notes/rome/idea"));
let d = SourceRecord::new("document", "rome-aqueducts", "", "t", "now".into());
assert!(d.summary().contains("document: rome-aqueducts"));
}
}