pub mod parse;
pub mod refresh;
#[cfg(test)]
mod tests;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use serde::Serialize;
use serde_json::{Value, json};
pub use parse::{ParsedMemory, parse_memory_file};
#[derive(Debug, Clone)]
pub struct ImportOptions {
pub dir: PathBuf,
pub palace: String,
pub dry_run: bool,
pub refresh: bool,
pub allow_secret_like: bool,
pub memory_socket: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportStatus {
Created,
Skipped,
Refreshed,
WouldCreate,
WouldSkip,
WouldRefresh,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileResult {
pub file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub status: ImportStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub drawer_id: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ImportReport {
pub palace: String,
pub dir: String,
pub dry_run: bool,
pub total: usize,
pub created: usize,
pub skipped: usize,
pub refreshed: usize,
pub failed: usize,
pub files: Vec<FileResult>,
}
impl ImportReport {
fn tally(&mut self) {
self.total = self.files.len();
self.created = self.count(&[ImportStatus::Created, ImportStatus::WouldCreate]);
self.skipped = self.count(&[ImportStatus::Skipped, ImportStatus::WouldSkip]);
self.refreshed = self.count(&[ImportStatus::Refreshed, ImportStatus::WouldRefresh]);
self.failed = self.count(&[ImportStatus::Failed]);
}
fn count(&self, wanted: &[ImportStatus]) -> usize {
self.files
.iter()
.filter(|f| wanted.contains(&f.status))
.count()
}
}
pub async fn run_import(opts: &ImportOptions) -> anyhow::Result<ImportReport> {
let socket = match &opts.memory_socket {
Some(url) => url.clone(),
None => trusty_common::memory_rpc::resolve_memory_socket()
.context("resolve trusty-memory daemon address")?,
};
let mut report = ImportReport {
palace: opts.palace.clone(),
dir: opts.dir.display().to_string(),
dry_run: opts.dry_run,
total: 0,
created: 0,
skipped: 0,
refreshed: 0,
failed: 0,
files: Vec::new(),
};
for path in markdown_files(&opts.dir)? {
let mut result = import_one(&socket, opts, &path).await;
if opts.refresh {
refresh::verify_findable(&socket, &opts.palace, &mut result).await;
}
report.files.push(result);
}
report.tally();
Ok(report)
}
fn markdown_files(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
let entries = std::fs::read_dir(dir)
.with_context(|| format!("read memory directory {}", dir.display()))?;
let mut paths: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = entry.with_context(|| format!("read entry in {}", dir.display()))?;
let path = entry.path();
if !path.is_file() {
continue;
}
let is_md = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("md"));
if is_md {
paths.push(path);
}
}
paths.sort();
Ok(paths)
}
async fn import_one(socket: &Path, opts: &ImportOptions, path: &Path) -> FileResult {
let file = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
let source = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => return failure(file, None, format!("read failed: {e}"), Vec::new()),
};
let parsed = match parse::parse_memory_file(&source) {
Ok(Some(p)) => p,
Ok(None) => {
return FileResult {
file,
name: None,
status: skip_status(opts.dry_run),
drawer_id: None,
tags: Vec::new(),
detail: Some("not a memory file (no YAML frontmatter)".to_string()),
};
}
Err(e) => return failure(file, None, format!("parse failed: {e:#}"), Vec::new()),
};
match existing_drawer(socket, &opts.palace, &parsed).await {
Ok(Existing::Same(drawer_id)) => {
return skipped(file, parsed, drawer_id, "already imported", opts.dry_run);
}
Ok(Existing::Drifted(drawer_id)) => {
if !opts.refresh {
return skipped(
file,
parsed,
drawer_id,
"already imported, but the stored drawer's text has drifted from \
this file — left unchanged, never duplicated; re-run with \
`refresh` to replace it",
opts.dry_run,
);
}
return refresh_one(socket, opts, file, parsed, &drawer_id).await;
}
Ok(Existing::Absent) => {}
Err(e) => {
return failure(
file,
Some(parsed.name),
format!("dedup check failed: {e:#}"),
parsed.tags,
);
}
}
if opts.dry_run {
return FileResult {
file,
name: Some(parsed.name),
status: ImportStatus::WouldCreate,
drawer_id: None,
tags: parsed.tags,
detail: None,
};
}
match write_drawer(socket, opts, &parsed).await {
Ok(drawer_id) => FileResult {
file,
name: Some(parsed.name),
status: ImportStatus::Created,
drawer_id: Some(drawer_id),
tags: parsed.tags,
detail: None,
},
Err(e) => failure(
file,
Some(parsed.name),
format!("write failed: {e:#}"),
parsed.tags,
),
}
}
async fn refresh_one(
socket: &Path,
opts: &ImportOptions,
file: String,
parsed: ParsedMemory,
drawer_id: &str,
) -> FileResult {
if opts.dry_run {
return FileResult {
file,
name: Some(parsed.name),
status: ImportStatus::WouldRefresh,
drawer_id: Some(drawer_id.to_string()),
tags: parsed.tags,
detail: Some("the stored drawer's text has drifted from this file".to_string()),
};
}
match refresh::refresh_drawer(socket, opts, &parsed, drawer_id).await {
Ok(new_id) => FileResult {
file,
name: Some(parsed.name),
status: ImportStatus::Refreshed,
drawer_id: Some(new_id),
tags: parsed.tags,
detail: Some(format!("replaced the drifted drawer {drawer_id}")),
},
Err(e) => failure(file, Some(parsed.name), e.to_string(), parsed.tags),
}
}
fn skipped(
file: String,
parsed: ParsedMemory,
drawer_id: String,
detail: &str,
dry_run: bool,
) -> FileResult {
FileResult {
file,
name: Some(parsed.name),
status: skip_status(dry_run),
drawer_id: Some(drawer_id),
tags: parsed.tags,
detail: Some(detail.to_string()),
}
}
fn failure(file: String, name: Option<String>, detail: String, tags: Vec<String>) -> FileResult {
FileResult {
file,
name,
status: ImportStatus::Failed,
drawer_id: None,
tags,
detail: Some(detail),
}
}
fn skip_status(dry_run: bool) -> ImportStatus {
if dry_run {
ImportStatus::WouldSkip
} else {
ImportStatus::Skipped
}
}
const DEDUP_CANDIDATE_LIMIT: usize = 200;
#[derive(Debug, PartialEq, Eq)]
enum Existing {
Same(String),
Drifted(String),
Absent,
}
async fn existing_drawer(
socket: &Path,
palace: &str,
parsed: &ParsedMemory,
) -> anyhow::Result<Existing> {
let result = trusty_common::memory_rpc::call_memory_tool_at(
socket,
"memory_list",
json!({ "palace": palace, "tag": parsed.name, "limit": DEDUP_CANDIDATE_LIMIT }),
)
.await?;
let drawers = result
.get("drawers")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
if drawers.len() >= DEDUP_CANDIDATE_LIMIT {
anyhow::bail!(
"the tag `{}` fills the whole {DEDUP_CANDIDATE_LIMIT}-drawer candidate page, \
so the candidate set is truncated and absence cannot be proven",
parsed.name
);
}
let self_linking = links_to(&parsed.text, &parsed.name);
let own: Vec<(&str, &str)> = drawers
.iter()
.filter_map(|d| {
let id = d.get("drawer_id").and_then(Value::as_str)?;
let content = d.get("content").and_then(Value::as_str).unwrap_or_default();
(self_linking || !links_to(content, &parsed.name)).then_some((id, content))
})
.collect();
match own.as_slice() {
[] => Ok(Existing::Absent),
[(id, content)] => Ok(if has_headline_of(content, &parsed.text) {
Existing::Same((*id).to_string())
} else {
Existing::Drifted((*id).to_string())
}),
many => match many.iter().find(|(_, c)| has_headline_of(c, &parsed.text)) {
Some((id, _)) => Ok(Existing::Same((*id).to_string())),
None => anyhow::bail!(
"{} drawers tagged `{}` could each be this file's own drawer and none \
carries its headline — refusing to guess",
many.len(),
parsed.name
),
},
}
}
fn links_to(text: &str, slug: &str) -> bool {
parse::wikilink_targets(text).iter().any(|t| t == slug)
}
fn has_headline_of(drawer_content: &str, derived_text: &str) -> bool {
let headline = derived_text.lines().next().unwrap_or_default();
if headline.is_empty() {
drawer_content.trim() == derived_text.trim()
} else {
drawer_content.lines().next().unwrap_or_default() == headline
}
}
async fn write_drawer(
socket: &Path,
opts: &ImportOptions,
parsed: &ParsedMemory,
) -> anyhow::Result<String> {
remember(
socket,
&opts.palace,
&parsed.text,
&parsed.tags,
opts.allow_secret_like,
)
.await
}
pub(crate) async fn remember(
socket: &Path,
palace: &str,
text: &str,
tags: &[String],
allow_secret_like: bool,
) -> anyhow::Result<String> {
let result = trusty_common::memory_rpc::call_memory_tool_at(
socket,
"memory_remember",
json!({
"palace": palace,
"text": text,
"tags": tags,
"force": true,
"allow_secret_like": allow_secret_like,
}),
)
.await?;
match result.get("drawer_id").and_then(Value::as_str) {
Some(id) => Ok(id.to_string()),
None => {
let reason = result
.get("reason")
.or_else(|| result.get("status"))
.and_then(Value::as_str)
.unwrap_or("no drawer_id in response");
anyhow::bail!("trusty-memory declined the write: {reason}")
}
}
}