use std::path::{Path, PathBuf};
use crate::config::Config;
use crate::graph::GraphStats;
use crate::knowledge::UpdateResult;
#[derive(Debug, Clone, Default)]
pub struct RefreshOptions {
pub ingest_knowledge: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QmdRefresh {
NotConfigured,
Refreshed,
Failed(String),
}
#[derive(Debug, Default)]
pub struct RefreshReport {
pub graph: Option<GraphStats>,
pub search_indexed: bool,
pub vault: Option<PathBuf>,
pub qmd_refreshed: bool,
pub knowledge: Option<UpdateResult>,
pub warnings: Vec<String>,
}
impl RefreshReport {
pub fn is_clean(&self) -> bool {
self.warnings.is_empty()
}
}
pub fn refresh_derived_views(path: &Path, config: &Config, opts: &RefreshOptions) -> RefreshReport {
refresh_derived_views_at(
path,
config,
opts,
&crate::graph::db_path(),
&crate::search_index::SearchIndex::default_db_path(),
)
}
pub(crate) fn refresh_derived_views_at(
path: &Path,
config: &Config,
opts: &RefreshOptions,
graph_db: &Path,
search_db: &Path,
) -> RefreshReport {
let mut report = RefreshReport::default();
match crate::graph::rebuild_index_at(config, graph_db) {
Ok(stats) => report.graph = Some(stats),
Err(error) => {
tracing::warn!(error = %error, artifact = %path.display(), "graph index rebuild failed");
report
.warnings
.push(format!("graph index rebuild: {error}"));
}
}
match crate::search_index::SearchIndex::open_at(search_db, config)
.and_then(|index| index.upsert_file(path))
{
Ok(()) => report.search_indexed = true,
Err(error) => {
tracing::warn!(error = %error, artifact = %path.display(), "search index upsert failed");
report.warnings.push(format!("search index: {error}"));
}
}
match crate::vault::sync_file(path, config) {
Ok(Some(vault_path)) => {
crate::events::append_event(crate::events::MinutesEvent::VaultSynced {
source_path: path.display().to_string(),
vault_path: vault_path.display().to_string(),
strategy: config.vault.strategy.clone(),
});
report.vault = Some(vault_path);
}
Ok(None) => {}
Err(error) => {
tracing::warn!(error = %error, artifact = %path.display(), "vault sync failed");
report.warnings.push(format!("vault sync: {error}"));
}
}
match refresh_qmd_collection(config) {
QmdRefresh::Refreshed => report.qmd_refreshed = true,
QmdRefresh::NotConfigured => {}
QmdRefresh::Failed(reason) => {
report.warnings.push(format!("qmd reindex: {reason}"));
}
}
if opts.ingest_knowledge {
refresh_knowledge(path, config, &mut report);
}
report
}
fn refresh_knowledge(path: &Path, config: &Config, report: &mut RefreshReport) {
if !config.knowledge.enabled || config.knowledge.path.as_os_str().is_empty() {
report.warnings.push(
"knowledge ingest requested, but the knowledge base is not enabled \
(set `knowledge.enabled` and `knowledge.path` in config.toml)"
.to_string(),
);
return;
}
match crate::knowledge::ingest_file(path, config) {
Ok(update) => {
if update.facts_written > 0 {
crate::events::append_event(crate::events::MinutesEvent::KnowledgeUpdated {
meeting_path: path.display().to_string(),
facts_written: update.facts_written,
facts_skipped: update.facts_skipped,
people_updated: update.people_updated.clone(),
});
}
report.knowledge = Some(update);
}
Err(error) => {
tracing::warn!(error = %error, artifact = %path.display(), "knowledge ingest failed");
report.warnings.push(format!("knowledge ingest: {error}"));
}
}
}
pub fn refresh_qmd_collection(config: &Config) -> QmdRefresh {
refresh_qmd_collection_with(config, "qmd")
}
fn refresh_qmd_collection_with(config: &Config, program: &str) -> QmdRefresh {
let Some(collection) = config.search.qmd_collection.as_ref() else {
return QmdRefresh::NotConfigured;
};
let status = crate::engine_process::command(program)
.args(["update", "-c", collection])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match status {
Ok(status) if status.success() => QmdRefresh::Refreshed,
Ok(status) => QmdRefresh::Failed(format!(
"`{program} update -c {collection}` exited {status}"
)),
Err(error) => {
tracing::debug!(error = %error, collection = %collection, "qmd update skipped");
QmdRefresh::Failed(format!(
"could not run `{program}` for `{collection}`: {error}"
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fixture(dir: &TempDir) -> (Config, PathBuf, PathBuf) {
let mut config = Config::default();
config.output_dir = dir.path().to_path_buf();
(
config,
dir.path().join("graph.db"),
dir.path().join("search.db"),
)
}
fn write_meeting(dir: &TempDir, name: &str) -> PathBuf {
let path = dir.path().join(name);
std::fs::write(
&path,
"---\ntitle: Test\ntype: meeting\ndate: 2026-07-26\n---\n\n## Summary\n\nhi\n",
)
.unwrap();
path
}
#[test]
fn refresh_options_default_leaves_knowledge_opt_in() {
assert!(!RefreshOptions::default().ingest_knowledge);
}
#[test]
fn qmd_refresh_is_skipped_when_no_collection_configured() {
let dir = TempDir::new().unwrap();
let (config, _, _) = fixture(&dir);
assert!(config.search.qmd_collection.is_none());
assert_eq!(refresh_qmd_collection(&config), QmdRefresh::NotConfigured);
}
#[test]
fn qmd_refresh_reports_failure_when_the_binary_cannot_be_spawned() {
let dir = TempDir::new().unwrap();
let (mut config, _, _) = fixture(&dir);
config.search.qmd_collection = Some("meetings".into());
let result = refresh_qmd_collection_with(&config, "minutes-no-such-binary-qmd");
match result {
QmdRefresh::Failed(reason) => {
assert!(
reason.contains("minutes-no-such-binary-qmd"),
"failure should name the program, got {reason:?}"
);
}
other => panic!("expected a spawn failure, got {other:?}"),
}
}
#[test]
fn search_index_upsert_targets_the_given_db_and_never_the_global_one() {
let dir = TempDir::new().unwrap();
let (config, graph_db, search_db) = fixture(&dir);
let artifact = write_meeting(&dir, "meeting.md");
let report = refresh_derived_views_at(
&artifact,
&config,
&RefreshOptions::default(),
&graph_db,
&search_db,
);
assert!(report.search_indexed, "warnings: {:?}", report.warnings);
assert!(
search_db.exists(),
"the injected search db path must be the target"
);
}
#[test]
fn graph_rebuild_targets_the_given_db_and_never_the_global_one() {
let dir = TempDir::new().unwrap();
let (config, graph_db, search_db) = fixture(&dir);
let artifact = write_meeting(&dir, "meeting.md");
let report = refresh_derived_views_at(
&artifact,
&config,
&RefreshOptions::default(),
&graph_db,
&search_db,
);
assert!(report.graph.is_some(), "graph should rebuild");
assert!(graph_db.exists(), "the injected db path must be the target");
}
#[test]
fn missing_output_dir_warns_instead_of_failing() {
let dir = TempDir::new().unwrap();
let (mut config, graph_db, search_db) = fixture(&dir);
config.output_dir = dir.path().join("does-not-exist");
let artifact = dir.path().join("meeting.md");
let report = refresh_derived_views_at(
&artifact,
&config,
&RefreshOptions::default(),
&graph_db,
&search_db,
);
assert!(report.graph.is_none());
assert!(!report.is_clean());
assert!(
report.warnings.iter().any(|w| w.contains("graph index")),
"expected a graph warning, got {:?}",
report.warnings
);
}
#[test]
fn clean_refresh_reports_no_warnings_and_skips_unconfigured_views() {
let dir = TempDir::new().unwrap();
let (config, graph_db, search_db) = fixture(&dir);
let artifact = write_meeting(&dir, "meeting.md");
let report = refresh_derived_views_at(
&artifact,
&config,
&RefreshOptions::default(),
&graph_db,
&search_db,
);
assert!(
report.is_clean(),
"unexpected warnings: {:?}",
report.warnings
);
assert!(report.graph.is_some());
assert!(report.vault.is_none());
assert!(!report.qmd_refreshed);
assert!(report.knowledge.is_none());
}
#[test]
fn ingest_on_a_disabled_knowledge_base_warns_instead_of_faking_success() {
let dir = TempDir::new().unwrap();
let (config, graph_db, search_db) = fixture(&dir);
let artifact = write_meeting(&dir, "meeting.md");
assert!(!config.knowledge.enabled);
let opts = RefreshOptions {
ingest_knowledge: true,
};
let report = refresh_derived_views_at(&artifact, &config, &opts, &graph_db, &search_db);
assert!(
report.knowledge.is_none(),
"a disabled knowledge base must not report a zero-fact success"
);
assert!(
report
.warnings
.iter()
.any(|w| w.contains("knowledge base is not enabled")),
"expected a disabled-knowledge warning, got {:?}",
report.warnings
);
}
#[test]
fn restricted_artifacts_are_refused_by_knowledge_ingest() {
let dir = TempDir::new().unwrap();
let knowledge_dir = TempDir::new().unwrap();
let (mut config, graph_db, search_db) = fixture(&dir);
config.knowledge.enabled = true;
config.knowledge.path = knowledge_dir.path().to_path_buf();
let artifact = dir.path().join("restricted.md");
std::fs::write(
&artifact,
"---\ntitle: Private\ntype: meeting\ndate: 2026-07-26\nsensitivity: restricted\n---\n\n## Summary\n\nhi\n",
)
.unwrap();
let opts = RefreshOptions {
ingest_knowledge: true,
};
let report = refresh_derived_views_at(&artifact, &config, &opts, &graph_db, &search_db);
assert!(report.knowledge.is_none());
assert!(
report.warnings.iter().any(|w| w.contains("restricted")),
"expected the restricted refusal to surface, got {:?}",
report.warnings
);
}
}