use crate::config::Config;
use crate::error::SearchError;
use crate::markdown::{extract_field, split_frontmatter, Frontmatter, IntentKind};
use crate::overlays;
use chrono::Local;
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
/// Directories within `output_dir` that should be excluded from search results.
/// These contain archived, processed, or failed files that are not active meetings.
const EXCLUDED_DIRS: &[&str] = &["archive", "processed", "failed", "failed-captures"];
/// Walk `dir` for `.md` files, skipping excluded subdirectories.
fn walk_meeting_files(dir: &Path) -> impl Iterator<Item = walkdir::DirEntry> {
WalkDir::new(dir)
.follow_links(true)
.into_iter()
.filter_entry(|e| {
if e.file_type().is_dir() {
let name = e.file_name().to_string_lossy();
!EXCLUDED_DIRS.contains(&name.as_ref())
} else {
true
}
})
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
}
// ──────────────────────────────────────────────────────────────
// Built-in search: walk dir + case-insensitive text match.
// Zero dependencies beyond walkdir. Fast enough for <1000 files.
//
// Config can swap to QMD engine for semantic search:
// [search]
// engine = "qmd"
// qmd_collection = "meetings"
// ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize)]
pub struct SearchResult {
pub path: PathBuf,
pub title: String,
pub date: String,
pub content_type: String,
pub snippet: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub matched_via_alias: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct IntentResult {
pub path: PathBuf,
pub title: String,
pub date: String,
pub content_type: String,
pub kind: IntentKind,
pub what: String,
pub who: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub who_original: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub who_provenance: Option<String>,
pub status: String,
pub by_date: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReportEntry {
pub path: PathBuf,
pub title: String,
pub date: String,
pub what: String,
pub who: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub who_original: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub who_provenance: Option<String>,
pub by_date: Option<String>,
/// Frontmatter v2: optional authority grade ("high" | "medium" | "low").
/// Propagated from the source decision when present. None for pre-v2
/// frontmatter.
#[serde(skip_serializing_if = "Option::is_none")]
pub authority: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DecisionConflict {
pub topic: String,
pub latest: ReportEntry,
pub previous: Vec<ReportEntry>,
/// Frontmatter v2: when the latest decision explicitly `supersedes` an
/// earlier one, this carries the supersession rationale. Consumers like
/// `/minutes-lint` should treat resolved conflicts as informational
/// rather than red flags. None means this is an unresolved contradiction.
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct OwnerResolution {
who: Option<String>,
who_original: Option<String>,
who_provenance: Option<String>,
}
#[derive(Debug, Clone)]
struct SpeakerOwner {
name: String,
provenance: String,
}
fn speaker_overlay_map(
frontmatter: &Frontmatter,
overlay_db_path: &Path,
meeting_path: &Path,
) -> HashMap<String, SpeakerOwner> {
let mut speakers = frontmatter
.speaker_map
.iter()
.filter(|attr| attr.confidence == crate::diarize::Confidence::High)
.map(|attr| {
(
attr.speaker_label.clone(),
SpeakerOwner {
name: attr.name.clone(),
provenance: "speaker_map".to_string(),
},
)
})
.collect::<HashMap<_, _>>();
match overlays::load_speaker_confirmations_for_meeting_at(overlay_db_path, meeting_path) {
Ok(confirmations) => {
for confirmation in confirmations {
speakers.insert(
confirmation.speaker_label,
SpeakerOwner {
name: confirmation.name,
provenance: "speaker overlay".to_string(),
},
);
}
}
Err(error) => {
tracing::warn!(
path = %meeting_path.display(),
error = %error,
"failed to load speaker overlays for reporting"
);
}
}
speakers
}
fn resolve_owner_with_speaker_overlays(
who: Option<&str>,
speaker_overlays: &HashMap<String, SpeakerOwner>,
) -> OwnerResolution {
let Some(raw) = who.map(str::trim).filter(|value| !value.is_empty()) else {
return OwnerResolution::default();
};
if let Some(speaker) = speaker_overlays.get(raw) {
return OwnerResolution {
who: Some(speaker.name.clone()),
who_original: Some(raw.to_string()),
who_provenance: Some(speaker.provenance.clone()),
};
}
OwnerResolution {
who: Some(raw.to_string()),
who_original: None,
who_provenance: None,
}
}
fn owner_matches(resolution: &OwnerResolution, owner_lower: &str) -> bool {
resolution
.who
.as_ref()
.is_some_and(|who| who.to_lowercase().contains(owner_lower))
|| resolution
.who_original
.as_ref()
.is_some_and(|who| who.to_lowercase().contains(owner_lower))
}
fn explicit_supersedes_resolution(
latest_supersedes: Option<&str>,
conflicting_previous: &[ReportEntry],
) -> Option<String> {
let value = latest_supersedes
.map(str::trim)
.filter(|value| !value.is_empty())?;
// `supersedes` is a free-text pointer. It's reliable for a simple
// one-new-decision-replaces-one-prior-decision case, but not strong enough
// to auto-resolve an entire topic arc when multiple contradictory prior
// decisions remain. Stay conservative so `/minutes-lint` doesn't hide a
// still-live conflict as "resolved".
if conflicting_previous.len() != 1 {
return None;
}
if !supersedes_references_previous_decision(value, &conflicting_previous[0]) {
return None;
}
Some(format!("Resolved by explicit supersedes: {}", value))
}
fn supersedes_references_previous_decision(supersedes: &str, previous: &ReportEntry) -> bool {
let supersedes_norm = normalize_decision_value(supersedes);
if supersedes_norm.is_empty() {
return false;
}
let previous_date = previous
.date
.split('T')
.next()
.unwrap_or(previous.date.as_str());
let previous_date_norm = normalize_decision_value(previous_date);
if !previous_date_norm.is_empty() && supersedes_norm.contains(&previous_date_norm) {
return true;
}
let previous_title_norm = normalize_decision_value(&previous.title);
if previous_title_norm.len() >= 4 && supersedes_norm.contains(&previous_title_norm) {
return true;
}
let previous_what_norm = normalize_decision_value(&previous.what);
if previous_what_norm.is_empty() {
return false;
}
let supersedes_tokens = supersedes_norm
.split_whitespace()
.filter(|token| token.len() >= 4)
.collect::<std::collections::HashSet<_>>();
let previous_tokens = previous_what_norm
.split_whitespace()
.filter(|token| token.len() >= 4)
.collect::<std::collections::HashSet<_>>();
supersedes_tokens
.intersection(&previous_tokens)
.take(2)
.count()
>= 2
}
#[derive(Debug, Clone, Serialize)]
pub struct StaleCommitment {
pub kind: IntentKind,
pub entry: ReportEntry,
pub meetings_since: usize,
pub age_days: i64,
pub reasons: Vec<String>,
pub latest_follow_up: Option<MeetingReference>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConsistencyReport {
pub decision_conflicts: Vec<DecisionConflict>,
pub stale_commitments: Vec<StaleCommitment>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TopicSummary {
pub topic: String,
pub count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct MeetingReference {
pub path: PathBuf,
pub title: String,
pub date: String,
pub content_type: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PersonProfile {
pub name: String,
pub recent_meetings: Vec<MeetingReference>,
pub open_intents: Vec<IntentResult>,
pub recent_decisions: Vec<ReportEntry>,
pub top_topics: Vec<TopicSummary>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CrossMeetingResearch {
pub query: String,
pub related_decisions: Vec<ReportEntry>,
pub related_open_intents: Vec<IntentResult>,
pub recent_meetings: Vec<MeetingReference>,
pub related_topics: Vec<TopicSummary>,
}
#[derive(Default)]
pub struct SearchFilters {
pub content_type: Option<String>,
pub since: Option<String>,
pub attendee: Option<String>,
pub intent_kind: Option<IntentKind>,
pub owner: Option<String>,
pub recorded_by: Option<String>,
}
/// Resolve a meeting file by slug prefix (date-title pattern).
/// Returns the first match found in the output directory.
pub fn resolve_slug(slug: &str, config: &Config) -> Option<PathBuf> {
if slug.is_empty() {
return None;
}
let dir = &config.output_dir;
if !dir.exists() {
return None;
}
for entry in walk_meeting_files(dir) {
let filename = entry
.path()
.file_stem()
.unwrap_or_default()
.to_string_lossy();
if filename.to_lowercase().contains(&slug.to_lowercase()) {
return Some(entry.path().to_path_buf());
}
}
None
}
pub fn cross_meeting_research(
query: &str,
config: &Config,
filters: &SearchFilters,
) -> Result<CrossMeetingResearch, SearchError> {
let dir = &config.output_dir;
if !dir.exists() {
return Err(SearchError::DirNotFound(dir.display().to_string()));
}
let query_lower = query.to_lowercase();
let mut related_decisions = Vec::new();
let mut related_open_intents = Vec::new();
let mut recent_meetings = Vec::new();
let mut topic_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let overlay_db_path = overlays::default_db_path();
for entry in walk_meeting_files(dir) {
let path = entry.path();
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "skipping file in cross-meeting research");
continue;
}
};
let (frontmatter_str, _) = split_frontmatter(&content);
if frontmatter_str.is_empty() {
continue;
}
let frontmatter: Frontmatter = match serde_yaml::from_str(frontmatter_str) {
Ok(frontmatter) => frontmatter,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "skipping malformed frontmatter in cross-meeting research");
continue;
}
};
let content_type = match frontmatter.r#type {
crate::markdown::ContentType::Meeting => "meeting".to_string(),
crate::markdown::ContentType::Memo => "memo".to_string(),
crate::markdown::ContentType::Dictation => "dictation".to_string(),
};
let speaker_overlays = speaker_overlay_map(&frontmatter, &overlay_db_path, path);
if let Some(ref type_filter) = filters.content_type {
if content_type != *type_filter {
continue;
}
}
let date = frontmatter.date.to_rfc3339();
if let Some(ref since) = filters.since {
if date < *since {
continue;
}
}
if let Some(ref attendee) = filters.attendee {
let attendee_lower = attendee.to_lowercase();
let attendee_match = frontmatter
.attendees
.iter()
.any(|name| name.to_lowercase().contains(&attendee_lower))
|| frontmatter
.people
.iter()
.any(|person| person.to_lowercase().contains(&attendee_lower));
if !attendee_match {
continue;
}
}
let meeting_matches = frontmatter.title.to_lowercase().contains(&query_lower)
|| frontmatter
.context
.as_ref()
.map(|context| context.to_lowercase().contains(&query_lower))
.unwrap_or(false);
let mut matched_this_meeting = meeting_matches;
for decision in &frontmatter.decisions {
let topic = decision
.topic
.clone()
.unwrap_or_else(|| normalize_topic(&decision.text));
let haystack = format!("{} {}", topic, decision.text).to_lowercase();
if haystack.contains(&query_lower) {
matched_this_meeting = true;
if !topic.is_empty() {
*topic_counts.entry(topic).or_insert(0) += 1;
}
related_decisions.push(ReportEntry {
path: path.to_path_buf(),
title: frontmatter.title.clone(),
date: date.clone(),
what: decision.text.clone(),
who: None,
who_original: None,
who_provenance: None,
by_date: None,
authority: decision.authority.clone(),
});
}
}
for intent in &frontmatter.intents {
let owner_resolution =
resolve_owner_with_speaker_overlays(intent.who.as_deref(), &speaker_overlays);
let haystack = format!(
"{} {} {} {} {}",
intent.what,
owner_resolution.who.clone().unwrap_or_default(),
owner_resolution.who_original.clone().unwrap_or_default(),
intent.status,
intent.by_date.clone().unwrap_or_default()
)
.to_lowercase();
if !haystack.contains(&query_lower) {
continue;
}
matched_this_meeting = true;
let topic = normalize_topic(&intent.what);
if !topic.is_empty() {
*topic_counts.entry(topic).or_insert(0) += 1;
}
if intent.status == "open" {
related_open_intents.push(IntentResult {
path: path.to_path_buf(),
title: frontmatter.title.clone(),
date: date.clone(),
content_type: content_type.clone(),
kind: intent.kind,
what: intent.what.clone(),
who: owner_resolution.who.clone(),
who_original: owner_resolution.who_original.clone(),
who_provenance: owner_resolution.who_provenance.clone(),
status: intent.status.clone(),
by_date: intent.by_date.clone(),
});
}
}
if matched_this_meeting {
recent_meetings.push(MeetingReference {
path: path.to_path_buf(),
title: frontmatter.title.clone(),
date,
content_type,
});
}
}
related_decisions.sort_by(|a, b| b.date.cmp(&a.date));
related_open_intents.sort_by(|a, b| b.date.cmp(&a.date));
recent_meetings.sort_by(|a, b| b.date.cmp(&a.date));
let mut related_topics: Vec<TopicSummary> = topic_counts
.into_iter()
.map(|(topic, count)| TopicSummary { topic, count })
.collect();
related_topics.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.topic.cmp(&b.topic)));
related_decisions.truncate(10);
related_open_intents.truncate(10);
recent_meetings.truncate(10);
related_topics.truncate(5);
Ok(CrossMeetingResearch {
query: query.to_string(),
related_decisions,
related_open_intents,
recent_meetings,
related_topics,
})
}
/// Search all markdown files in the meetings directory.
pub fn search(
query: &str,
config: &Config,
filters: &SearchFilters,
) -> Result<Vec<SearchResult>, SearchError> {
search_with_mode(query, config, filters, crate::search_index::SyncMode::Auto)
}
/// Search with explicit sync mode. Lets the CLI expose `--sync` / `--no-sync`
/// flags for piped/scripted use cases without making every other caller think
/// about freshness.
///
/// Logs sync stats (indexed/updated/removed/duration_ms) at INFO level when
/// any work was done. Empty/no-op syncs stay silent. The duration_ms field is
/// the canary for the watcher coalescer decision: if p95 starts approaching
/// the 80ms UI debounce we know the corpus has outgrown the per-file scan.
pub fn search_with_mode(
query: &str,
config: &Config,
filters: &SearchFilters,
mode: crate::search_index::SyncMode,
) -> Result<Vec<SearchResult>, SearchError> {
search_with_mode_and_vocabulary(query, config, filters, mode, None)
}
fn search_with_mode_and_vocabulary(
query: &str,
config: &Config,
filters: &SearchFilters,
mode: crate::search_index::SyncMode,
vocabulary_override: Option<&crate::vocabulary::VocabularyStore>,
) -> Result<Vec<SearchResult>, SearchError> {
let dir = &config.output_dir;
if !dir.exists() {
return Err(SearchError::DirNotFound(dir.display().to_string()));
}
let index = crate::search_index::SearchIndex::open(config)?;
let stats = index.sync(config, mode)?;
if stats.indexed + stats.updated + stats.removed + stats.errored > 0 {
tracing::info!(
indexed = stats.indexed,
updated = stats.updated,
removed = stats.removed,
errored = stats.errored,
duration_ms = stats.duration_ms,
"search index sync"
);
}
let expansions = vocabulary_search_expansions(query, vocabulary_override);
if expansions.len() <= 1 {
return Ok(index.search(query, filters, None)?);
}
let original_key = search_expansion_key(query);
let mut merged = Vec::new();
let mut seen_paths = std::collections::HashSet::new();
for expansion in expansions {
let expansion_key = search_expansion_key(&expansion);
for mut result in index.search(&expansion, filters, None)? {
if !seen_paths.insert(result.path.clone()) {
continue;
}
if expansion_key != original_key {
result.matched_via_alias = Some(expansion.clone());
}
merged.push(result);
}
}
Ok(merged)
}
fn vocabulary_search_expansions(
query: &str,
vocabulary_override: Option<&crate::vocabulary::VocabularyStore>,
) -> Vec<String> {
if query.trim().is_empty() {
return Vec::new();
}
let mut expansions = vocabulary_override
.map(|store| store.search_expansions(query))
.unwrap_or_else(|| {
crate::vocabulary::load()
.map(|store| store.search_expansions(query))
.unwrap_or_else(|error| {
tracing::debug!(error = %error, "could not load vocabulary for search expansion");
Vec::new()
})
});
if expansions.is_empty() {
expansions.push(query.trim().to_string());
} else if !expansions
.iter()
.any(|candidate| search_expansion_key(candidate) == search_expansion_key(query))
{
expansions.insert(0, query.trim().to_string());
}
let mut seen = std::collections::HashSet::new();
expansions
.into_iter()
.filter_map(|candidate| {
let trimmed = candidate.trim();
if trimmed.is_empty() {
return None;
}
let key = search_expansion_key(trimmed);
if seen.insert(key) {
Some(trimmed.to_string())
} else {
None
}
})
.take(8)
.collect()
}
fn search_expansion_key(value: &str) -> String {
value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_ascii_lowercase()
}
/// Search structured intents across all markdown files in the meetings directory.
pub fn search_intents(
query: &str,
config: &Config,
filters: &SearchFilters,
) -> Result<Vec<IntentResult>, SearchError> {
search_intents_at(query, config, filters, &overlays::default_db_path())
}
fn search_intents_at(
query: &str,
config: &Config,
filters: &SearchFilters,
overlay_db_path: &Path,
) -> Result<Vec<IntentResult>, SearchError> {
let dir = &config.output_dir;
if !dir.exists() {
return Err(SearchError::DirNotFound(dir.display().to_string()));
}
let query_lower = query.to_lowercase();
let mut results = Vec::new();
for entry in walk_meeting_files(dir) {
let path = entry.path();
match process_intent_file(path, &query_lower, filters, overlay_db_path) {
Ok(mut file_results) => results.append(&mut file_results),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "skipping file in intent search");
}
}
}
results.sort_by(|a, b| b.date.cmp(&a.date));
Ok(results)
}
pub fn consistency_report(
config: &Config,
owner: Option<&str>,
stale_after_days: i64,
) -> Result<ConsistencyReport, SearchError> {
consistency_report_at(
config,
owner,
stale_after_days,
&overlays::default_db_path(),
)
}
fn consistency_report_at(
config: &Config,
owner: Option<&str>,
stale_after_days: i64,
overlay_db_path: &Path,
) -> Result<ConsistencyReport, SearchError> {
let dir = &config.output_dir;
if !dir.exists() {
return Err(SearchError::DirNotFound(dir.display().to_string()));
}
let mut parsed_frontmatters = Vec::new();
for entry in walk_meeting_files(dir) {
let path = entry.path();
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "skipping file in consistency report");
continue;
}
};
let (frontmatter_str, _) = split_frontmatter(&content);
if frontmatter_str.is_empty() {
continue;
}
match serde_yaml::from_str::<Frontmatter>(frontmatter_str) {
Ok(frontmatter) => parsed_frontmatters.push((path.to_path_buf(), frontmatter)),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "skipping malformed frontmatter in consistency report");
}
}
}
parsed_frontmatters.sort_by_key(|entry| entry.1.date);
let owner_lower = owner.map(|value| value.to_lowercase());
let now = Local::now();
// Each entry carries its source decision's `supersedes` value alongside the
// ReportEntry so we can detect documented supersessions when the topic
// group has conflicting decisions.
let mut decision_groups: std::collections::HashMap<String, Vec<(ReportEntry, Option<String>)>> =
std::collections::HashMap::new();
let mut stale_commitments = Vec::new();
for (path, frontmatter) in &parsed_frontmatters {
let speaker_overlays = speaker_overlay_map(frontmatter, overlay_db_path, path);
for decision in &frontmatter.decisions {
let topic = decision
.topic
.as_deref()
.map(normalize_topic)
.filter(|topic| !topic.is_empty())
.unwrap_or_else(|| normalize_topic(&decision.text));
if topic.is_empty() {
continue;
}
decision_groups.entry(topic).or_default().push((
ReportEntry {
path: path.clone(),
title: frontmatter.title.clone(),
date: frontmatter.date.to_rfc3339(),
what: decision.text.clone(),
who: None,
who_original: None,
who_provenance: None,
by_date: None,
authority: decision.authority.clone(),
},
decision.supersedes.clone(),
));
}
for intent in &frontmatter.intents {
if !matches!(intent.kind, IntentKind::Commitment | IntentKind::ActionItem) {
continue;
}
if intent.status != "open" {
continue;
}
let owner_resolution =
resolve_owner_with_speaker_overlays(intent.who.as_deref(), &speaker_overlays);
if let Some(ref owner_lower) = owner_lower {
if !owner_matches(&owner_resolution, owner_lower) {
continue;
}
}
let newer_meetings: Vec<_> = parsed_frontmatters
.iter()
.filter(|(_, newer)| newer.date > frontmatter.date)
.collect();
let meetings_since = newer_meetings.len();
let age_days = now.signed_duration_since(frontmatter.date).num_days();
let latest_follow_up =
newer_meetings
.last()
.map(|(path, frontmatter)| MeetingReference {
path: path.clone(),
title: frontmatter.title.clone(),
date: frontmatter.date.to_rfc3339(),
content_type: match frontmatter.r#type {
crate::markdown::ContentType::Meeting => "meeting".to_string(),
crate::markdown::ContentType::Memo => "memo".to_string(),
crate::markdown::ContentType::Dictation => "dictation".to_string(),
},
});
let mut reasons = Vec::new();
if age_days >= stale_after_days {
reasons.push(format!("{} days old", age_days));
}
if meetings_since >= 3 {
reasons.push(format!("{} newer meetings since", meetings_since));
}
if let Some(by_date) = &intent.by_date {
if meetings_since >= 1 || age_days >= 1 {
reasons.push(format!("still open with due date {}", by_date));
}
}
if intent
.who
.as_deref()
.is_none_or(|who| who.trim().is_empty())
{
reasons.push("still open without an owner".to_string());
}
if !reasons.is_empty() {
stale_commitments.push(StaleCommitment {
kind: intent.kind,
entry: ReportEntry {
path: path.clone(),
title: frontmatter.title.clone(),
date: frontmatter.date.to_rfc3339(),
what: intent.what.clone(),
who: owner_resolution.who.clone(),
who_original: owner_resolution.who_original.clone(),
who_provenance: owner_resolution.who_provenance.clone(),
by_date: intent.by_date.clone(),
authority: None,
},
meetings_since,
age_days,
reasons,
latest_follow_up,
});
}
}
}
let mut decision_conflicts = Vec::new();
for (topic, mut entries) in decision_groups {
entries.sort_by(|a, b| a.0.date.cmp(&b.0.date));
let mut unique_values = std::collections::HashSet::new();
for (entry, _) in &entries {
unique_values.insert(normalize_decision_value(&entry.what));
}
if unique_values.len() > 1 {
let (latest_entry, latest_supersedes) = entries.pop().expect("entries not empty");
let previous_entries: Vec<ReportEntry> =
entries.into_iter().map(|(entry, _)| entry).collect();
let resolution =
explicit_supersedes_resolution(latest_supersedes.as_deref(), &previous_entries);
decision_conflicts.push(DecisionConflict {
topic,
latest: latest_entry,
previous: previous_entries,
resolution,
});
}
}
decision_conflicts.sort_by(|a, b| b.latest.date.cmp(&a.latest.date));
stale_commitments.sort_by(|a, b| b.entry.date.cmp(&a.entry.date));
Ok(ConsistencyReport {
decision_conflicts,
stale_commitments,
})
}
pub fn person_profile(config: &Config, person: &str) -> Result<PersonProfile, SearchError> {
let dir = &config.output_dir;
if !dir.exists() {
return Err(SearchError::DirNotFound(dir.display().to_string()));
}
let person_lower = person.to_lowercase();
let mut parsed_frontmatters = Vec::new();
let overlay_db_path = overlays::default_db_path();
for entry in walk_meeting_files(dir) {
let path = entry.path();
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "skipping file in person profile");
continue;
}
};
let (frontmatter_str, _) = split_frontmatter(&content);
if frontmatter_str.is_empty() {
continue;
}
match serde_yaml::from_str::<Frontmatter>(frontmatter_str) {
Ok(frontmatter) => parsed_frontmatters.push((path.to_path_buf(), frontmatter)),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "skipping malformed frontmatter in person profile");
}
}
}
parsed_frontmatters.sort_by_key(|entry| std::cmp::Reverse(entry.1.date));
let mut recent_meetings = Vec::new();
let mut open_intents = Vec::new();
let mut recent_decisions = Vec::new();
let mut topic_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for (path, frontmatter) in parsed_frontmatters {
let content_type = match frontmatter.r#type {
crate::markdown::ContentType::Meeting => "meeting".to_string(),
crate::markdown::ContentType::Memo => "memo".to_string(),
crate::markdown::ContentType::Dictation => "dictation".to_string(),
};
let date = frontmatter.date.to_rfc3339();
let speaker_overlays = speaker_overlay_map(&frontmatter, &overlay_db_path, &path);
let attendee_match = frontmatter
.attendees
.iter()
.any(|attendee| attendee.to_lowercase().contains(&person_lower));
let linked_person_match = frontmatter
.people
.iter()
.any(|person| person.to_lowercase().contains(&person_lower))
|| frontmatter.entities.people.iter().any(|entity| {
entity.label.to_lowercase().contains(&person_lower)
|| entity
.aliases
.iter()
.any(|alias| alias.to_lowercase().contains(&person_lower))
});
let owned_intent_match = frontmatter.intents.iter().any(|intent| {
let owner_resolution =
resolve_owner_with_speaker_overlays(intent.who.as_deref(), &speaker_overlays);
owner_matches(&owner_resolution, &person_lower)
});
if !(attendee_match || linked_person_match || owned_intent_match) {
continue;
}
recent_meetings.push(MeetingReference {
path: path.clone(),
title: frontmatter.title.clone(),
date: date.clone(),
content_type: content_type.clone(),
});
for decision in &frontmatter.decisions {
recent_decisions.push(ReportEntry {
path: path.clone(),
title: frontmatter.title.clone(),
date: date.clone(),
what: decision.text.clone(),
who: None,
who_original: None,
who_provenance: None,
by_date: None,
authority: decision.authority.clone(),
});
let topic = decision
.topic
.clone()
.unwrap_or_else(|| normalize_topic(&decision.text));
if !topic.is_empty() {
*topic_counts.entry(topic).or_insert(0) += 1;
}
}
for intent in &frontmatter.intents {
let owner_resolution =
resolve_owner_with_speaker_overlays(intent.who.as_deref(), &speaker_overlays);
let owned_by_person = owner_matches(&owner_resolution, &person_lower);
if owned_by_person
&& intent.status == "open"
&& matches!(intent.kind, IntentKind::ActionItem | IntentKind::Commitment)
{
open_intents.push(IntentResult {
path: path.clone(),
title: frontmatter.title.clone(),
date: date.clone(),
content_type: content_type.clone(),
kind: intent.kind,
what: intent.what.clone(),
who: owner_resolution.who.clone(),
who_original: owner_resolution.who_original.clone(),
who_provenance: owner_resolution.who_provenance.clone(),
status: intent.status.clone(),
by_date: intent.by_date.clone(),
});
}
if attendee_match || owned_by_person {
let topic = normalize_topic(&intent.what);
if !topic.is_empty() {
*topic_counts.entry(topic).or_insert(0) += 1;
}
}
}
}
recent_meetings.truncate(5);
recent_decisions.truncate(5);
open_intents.truncate(10);
let mut top_topics: Vec<TopicSummary> = topic_counts
.into_iter()
.map(|(topic, count)| TopicSummary { topic, count })
.collect();
top_topics.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.topic.cmp(&b.topic)));
top_topics.truncate(5);
Ok(PersonProfile {
name: person.to_string(),
recent_meetings,
open_intents,
recent_decisions,
top_topics,
})
}
// Legacy walk-and-grep helper. The `search()` public API now delegates to the
// FTS5 index, but `cross_meeting_research`, `person_profile`, and
// `find_open_actions` (deferred to follow-up PRs) still walk files. They'll be
// migrated in their own PRs; meanwhile this stays so the helpers don't need
// to be reinvented later.
#[allow(dead_code)]
fn process_file(
path: &Path,
query: &str,
filters: &SearchFilters,
) -> Result<Option<SearchResult>, SearchError> {
let content = std::fs::read_to_string(path)?;
// Parse frontmatter
let (frontmatter_str, body) = split_frontmatter(&content);
let title = extract_field(frontmatter_str, "title").unwrap_or_default();
let date = extract_field(frontmatter_str, "date").unwrap_or_default();
let content_type = extract_field(frontmatter_str, "type").unwrap_or_else(|| "meeting".into());
// Apply filters
if let Some(ref type_filter) = filters.content_type {
if content_type != *type_filter {
return Ok(None);
}
}
if let Some(ref since) = filters.since {
if date < *since {
return Ok(None);
}
}
if let Some(ref attendee) = filters.attendee {
let attendees = extract_field(frontmatter_str, "attendees").unwrap_or_default();
if !attendees.to_lowercase().contains(&attendee.to_lowercase()) {
return Ok(None);
}
}
if let Some(ref recorded_by) = filters.recorded_by {
let recorded = extract_field(frontmatter_str, "recorded_by").unwrap_or_default();
if !recorded
.to_lowercase()
.contains(&recorded_by.to_lowercase())
{
return Ok(None);
}
}
// Text search (case-insensitive)
let body_lower = body.to_lowercase();
let title_lower = title.to_lowercase();
if body_lower.contains(query) || title_lower.contains(query) {
let snippet = extract_snippet(body, query);
Ok(Some(SearchResult {
path: path.to_path_buf(),
title,
date,
content_type,
snippet,
matched_via_alias: None,
}))
} else {
Ok(None)
}
}
fn process_intent_file(
path: &Path,
query: &str,
filters: &SearchFilters,
overlay_db_path: &Path,
) -> Result<Vec<IntentResult>, SearchError> {
let content = std::fs::read_to_string(path)?;
let (frontmatter_str, _) = split_frontmatter(&content);
if frontmatter_str.is_empty() {
return Ok(vec![]);
}
let frontmatter: Frontmatter = serde_yaml::from_str(frontmatter_str)
.map_err(|e| SearchError::Io(std::io::Error::other(e.to_string())))?;
let date = frontmatter.date.to_rfc3339();
let content_type = match frontmatter.r#type {
crate::markdown::ContentType::Meeting => "meeting".to_string(),
crate::markdown::ContentType::Memo => "memo".to_string(),
crate::markdown::ContentType::Dictation => "dictation".to_string(),
};
if let Some(ref type_filter) = filters.content_type {
if content_type != *type_filter {
return Ok(vec![]);
}
}
if let Some(ref since) = filters.since {
if date < *since {
return Ok(vec![]);
}
}
if let Some(ref attendee) = filters.attendee {
let attendee_lower = attendee.to_lowercase();
let attendee_match = frontmatter
.attendees
.iter()
.any(|name| name.to_lowercase().contains(&attendee_lower));
if !attendee_match {
return Ok(vec![]);
}
}
if let Some(ref recorded_by) = filters.recorded_by {
let matches = frontmatter
.recorded_by
.as_ref()
.is_some_and(|r| r.to_lowercase().contains(&recorded_by.to_lowercase()));
if !matches {
return Ok(vec![]);
}
}
let speaker_overlays = speaker_overlay_map(&frontmatter, overlay_db_path, path);
let mut results = Vec::new();
for intent in frontmatter.intents {
if let Some(kind) = filters.intent_kind {
if intent.kind != kind {
continue;
}
}
let owner_resolution =
resolve_owner_with_speaker_overlays(intent.who.as_deref(), &speaker_overlays);
if let Some(ref owner) = filters.owner {
let owner_lower = owner.to_lowercase();
if !owner_matches(&owner_resolution, &owner_lower) {
continue;
}
}
let haystack = format!(
"{} {} {} {} {} {}",
frontmatter.title,
intent.what,
owner_resolution.who.clone().unwrap_or_default(),
owner_resolution.who_original.clone().unwrap_or_default(),
intent.status,
intent.by_date.clone().unwrap_or_default()
)
.to_lowercase();
if !query.is_empty() && !haystack.contains(query) {
continue;
}
results.push(IntentResult {
path: path.to_path_buf(),
title: frontmatter.title.clone(),
date: date.clone(),
content_type: content_type.clone(),
kind: intent.kind,
what: intent.what,
who: owner_resolution.who,
who_original: owner_resolution.who_original,
who_provenance: owner_resolution.who_provenance,
status: intent.status,
by_date: intent.by_date,
});
}
Ok(results)
}
// split_frontmatter and extract_field are in markdown.rs (shared)
/// Find meetings with open action items, optionally filtered by assignee.
/// Parses YAML frontmatter for the structured action_items field.
pub fn find_open_actions(
config: &Config,
assignee: Option<&str>,
) -> Result<Vec<ActionResult>, SearchError> {
let dir = &config.output_dir;
if !dir.exists() {
return Ok(vec![]);
}
let mut results = Vec::new();
for entry in walk_meeting_files(dir) {
let path = entry.path();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let (fm_str, _) = split_frontmatter(&content);
let title = extract_field(fm_str, "title").unwrap_or_default();
let date = extract_field(fm_str, "date").unwrap_or_default();
// Parse action_items from frontmatter (YAML list)
// Look for lines like " - assignee: mat" within the action_items block
if !content.contains("action_items:") {
continue;
}
// Simple parse: find action_items section in frontmatter YAML
// Note: fm_str is already stripped of --- markers by split_frontmatter,
// so pass it directly — wrapping with --- would create a multi-document
// YAML that serde_yaml rejects.
let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(fm_str);
if let Ok(yaml) = parsed {
if let Some(items) = yaml.get("action_items").and_then(|v| v.as_sequence()) {
for item in items {
let item_assignee = item
.get("assignee")
.and_then(|v| v.as_str())
.unwrap_or("unassigned");
let item_status = item
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("open");
let item_task = item.get("task").and_then(|v| v.as_str()).unwrap_or("");
let item_due = item
.get("due")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if item_status != "open" {
continue;
}
if let Some(filter) = assignee {
let a = item_assignee.to_lowercase();
let f = filter.to_lowercase();
if a != f && !a.contains(&f) {
continue;
}
}
results.push(ActionResult {
meeting_path: path.to_path_buf(),
meeting_title: title.clone(),
meeting_date: date.clone(),
assignee: item_assignee.to_string(),
task: item_task.to_string(),
due: item_due,
});
}
}
}
}
results.sort_by(|a, b| b.meeting_date.cmp(&a.meeting_date));
Ok(results)
}
/// A structured action item result from cross-meeting search.
#[derive(Debug, Clone, Serialize)]
pub struct ActionResult {
pub meeting_path: PathBuf,
pub meeting_title: String,
pub meeting_date: String,
pub assignee: String,
pub task: String,
pub due: Option<String>,
}
/// Extract a snippet around the first match of the query.
#[allow(dead_code)]
fn extract_snippet(body: &str, query: &str) -> String {
// Find the query in the body case-insensitively.
// We search the original body to avoid byte-offset mismatch from to_lowercase().
let pos = body
.char_indices()
.position(|(i, _)| body[i..].to_lowercase().starts_with(query))
.and_then(|char_idx| body.char_indices().nth(char_idx).map(|(i, _)| i));
if let Some(pos) = pos {
let start = body[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
let end = body[pos..]
.find('\n')
.map(|i| pos + i)
.unwrap_or(body.len());
let line = body[start..end].trim();
if line.chars().count() > 200 {
let truncated: String = line.chars().take(200).collect();
format!("{}...", truncated)
} else {
line.to_string()
}
} else {
String::new()
}
}
fn normalize_topic(text: &str) -> String {
let stopwords = [
"a", "an", "and", "as", "at", "by", "for", "from", "in", "of", "on", "or", "the", "to",
"with", "we", "should", "will", "be", "is", "are", "use", "using",
];
text.split_whitespace()
.map(|word| word.trim_matches(|c: char| !c.is_alphanumeric()))
.filter(|word| !word.is_empty())
.filter(|word| !stopwords.contains(&word.to_lowercase().as_str()))
.take(4)
.map(|word| word.to_lowercase())
.collect::<Vec<_>>()
.join(" ")
}
fn normalize_decision_value(text: &str) -> String {
text.chars()
.map(|ch| {
if ch.is_alphanumeric() || ch.is_whitespace() {
ch.to_ascii_lowercase()
} else {
' '
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_file(dir: &Path, name: &str, content: &str) {
let path = dir.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
#[test]
fn search_finds_matching_content() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-17-test.md",
"---\ntitle: Test Meeting\ndate: 2026-03-17\ntype: meeting\n---\n\n## Transcript\n\nWe discussed pricing strategy in detail.",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let filters = SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: None,
owner: None,
recorded_by: None,
};
let results = search("pricing", &config, &filters).unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].snippet.contains("pricing"));
}
#[test]
fn search_returns_empty_for_no_match() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"test.md",
"---\ntitle: Test\ndate: 2026-03-17\n---\n\nHello world.",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let filters = SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: None,
owner: None,
recorded_by: None,
};
let results = search("nonexistent", &config, &filters).unwrap();
assert!(results.is_empty());
}
#[test]
fn search_is_case_insensitive() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"test.md",
"---\ntitle: Test\ndate: 2026-03-17\n---\n\nPRICING discussion",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let filters = SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: None,
owner: None,
recorded_by: None,
};
let results = search("pricing", &config, &filters).unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn search_expands_vocabulary_aliases_with_provenance() {
let _guard = crate::test_home_env_lock();
let home = TempDir::new().unwrap();
unsafe {
std::env::set_var("HOME", home.path());
std::env::set_var("USERPROFILE", home.path());
}
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"test.md",
"---\ntitle: Writing Tools\ndate: 2026-05-01\ntype: meeting\n---\n\nWe discussed Automatic and Harper.",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let filters = SearchFilters::default();
let vocabulary = crate::vocabulary::VocabularyStore {
entries: vec![crate::vocabulary::VocabularyEntry {
kind: crate::vocabulary::VocabularyKind::Organization,
canonical: "Automattic".into(),
aliases: vec!["Automatic".into()],
..crate::vocabulary::VocabularyEntry::default()
}],
}
.normalized()
.unwrap();
let results = search_with_mode_and_vocabulary(
"Automattic",
&config,
&filters,
crate::search_index::SyncMode::Force,
Some(&vocabulary),
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].matched_via_alias.as_deref(), Some("Automatic"));
}
#[test]
fn search_filters_by_recorded_by() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"test.md",
"---\ntitle: Test\ndate: 2026-03-17\nrecorded_by: Mat Silver\ntype: meeting\n---\n\nPricing discussion",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let matching_filters = SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: None,
owner: None,
recorded_by: Some("mat".into()),
};
let non_matching_filters = SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: None,
owner: None,
recorded_by: Some("sarah".into()),
};
let matching_results = search("pricing", &config, &matching_filters).unwrap();
let non_matching_results = search("pricing", &config, &non_matching_filters).unwrap();
assert_eq!(matching_results.len(), 1);
assert!(non_matching_results.is_empty());
}
#[test]
fn search_empty_directory() {
let dir = TempDir::new().unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let filters = SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: None,
owner: None,
recorded_by: None,
};
let results = search("anything", &config, &filters).unwrap();
assert!(results.is_empty());
}
#[test]
fn split_frontmatter_works() {
let content = "---\ntitle: Test\ndate: 2026-03-17\n---\n\nBody text here.";
let (fm, body) = split_frontmatter(content);
assert!(fm.contains("title: Test"));
assert!(body.contains("Body text here"));
}
#[test]
fn extract_field_finds_value() {
let fm = "title: My Meeting\ndate: 2026-03-17\ntype: meeting";
assert_eq!(extract_field(fm, "title"), Some("My Meeting".into()));
assert_eq!(extract_field(fm, "type"), Some("meeting".into()));
assert_eq!(extract_field(fm, "nonexistent"), None);
}
#[test]
fn search_intents_returns_matching_structured_records() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-17-test.md",
"---\ntitle: Pricing Review\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 42m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions: []\nintents:\n - kind: action-item\n what: Send pricing doc\n who: mat\n status: open\n by_date: Friday\n - kind: commitment\n what: Share revised pricing model\n who: sarah\n status: open\n by_date: Tuesday\n---\n\n## Transcript\n\nWe discussed pricing.\n",
);
let filters = SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: None,
owner: None,
recorded_by: None,
};
let overlay_db = dir.path().join("overlays.db");
let results = process_intent_file(
&dir.path().join("2026-03-17-test.md"),
"pricing",
&filters,
&overlay_db,
)
.unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0].title, "Pricing Review");
assert!(results
.iter()
.any(|item| item.kind == IntentKind::ActionItem));
assert!(results
.iter()
.any(|item| item.kind == IntentKind::Commitment));
}
#[test]
fn search_intents_filters_by_kind_and_owner() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-17-test.md",
"---\ntitle: Pricing Review\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 42m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions: []\nintents:\n - kind: action-item\n what: Send pricing doc\n who: mat\n status: open\n by_date: Friday\n - kind: commitment\n what: Share revised pricing model\n who: sarah\n status: open\n by_date: Tuesday\n---\n\n## Transcript\n\nWe discussed pricing.\n",
);
let filters = SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: Some(IntentKind::Commitment),
owner: Some("sarah".into()),
recorded_by: None,
};
let overlay_db = dir.path().join("overlays.db");
let results = process_intent_file(
&dir.path().join("2026-03-17-test.md"),
"",
&filters,
&overlay_db,
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].kind, IntentKind::Commitment);
assert_eq!(results[0].who.as_deref(), Some("sarah"));
}
#[test]
fn search_intents_filters_owner_through_speaker_overlay() {
let dir = TempDir::new().unwrap();
let meeting = dir.path().join("2026-03-17-test.md");
create_test_file(
dir.path(),
"2026-03-17-test.md",
"---\ntitle: Pricing Review\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 42m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions: []\nspeaker_map:\n - speaker_label: SPEAKER_0\n name: Unknown Speaker\n confidence: medium\n source: llm\nintents:\n - kind: action-item\n what: Send pricing doc\n who: SPEAKER_0\n status: open\n by_date: Friday\n---\n\n## Transcript\n\n[SPEAKER_0 0:00] I'll send pricing.\n",
);
let overlay_db = dir.path().join("overlays.db");
crate::overlays::write_speaker_confirmation_at(
&overlay_db,
&meeting,
"SPEAKER_0",
"Alex Kim",
Some("Unknown Speaker"),
Some("test owner resolution"),
)
.unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let filters = SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: Some(IntentKind::ActionItem),
owner: Some("alex".into()),
recorded_by: None,
};
let results = search_intents_at("", &config, &filters, &overlay_db).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].who.as_deref(), Some("Alex Kim"));
assert_eq!(results[0].who_original.as_deref(), Some("SPEAKER_0"));
assert_eq!(
results[0].who_provenance.as_deref(),
Some("speaker overlay")
);
}
#[test]
fn search_intents_filter_by_recorded_by() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-17-test.md",
"---\ntitle: Pricing Review\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 42m\nstatus: complete\ntags: []\nattendees: []\npeople: []\nrecorded_by: Mat Silver\naction_items: []\ndecisions: []\nintents:\n - kind: action-item\n what: Send pricing doc\n who: mat\n status: open\n by_date: Friday\n---\n\n## Transcript\n\nWe discussed pricing.\n",
);
let matching_filters = SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: None,
owner: None,
recorded_by: Some("mat".into()),
};
let non_matching_filters = SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: None,
owner: None,
recorded_by: Some("sarah".into()),
};
let matching_results = process_intent_file(
&dir.path().join("2026-03-17-test.md"),
"",
&matching_filters,
&dir.path().join("overlays.db"),
)
.unwrap();
let non_matching_results = process_intent_file(
&dir.path().join("2026-03-17-test.md"),
"",
&non_matching_filters,
&dir.path().join("overlays.db"),
)
.unwrap();
assert_eq!(matching_results.len(), 1);
assert!(non_matching_results.is_empty());
}
#[test]
fn consistency_report_flags_conflicts_and_stale_commitments() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-01-a.md",
"---\ntitle: Pricing Decision\ntype: meeting\ndate: 2026-03-01T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Launch pricing at annual billing per month\n topic: pricing\nintents:\n - kind: commitment\n what: Send pricing doc\n who: case\n status: open\n by_date: March 8\n---\n\n## Transcript\n\nPricing discussion.\n",
);
create_test_file(
dir.path(),
"2026-03-12-b.md",
"---\ntitle: Pricing Revisit\ntype: meeting\ndate: 2026-03-12T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Launch pricing at monthly billing per month\n topic: pricing\nintents: []\n---\n\n## Transcript\n\nPricing changed.\n",
);
create_test_file(
dir.path(),
"2026-03-20-c.md",
"---\ntitle: Follow-up\ntype: meeting\ndate: 2026-03-20T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions: []\nintents: []\n---\n\n## Transcript\n\nFollow-up.\n",
);
create_test_file(
dir.path(),
"2026-03-25-d.md",
"---\ntitle: Another Follow-up\ntype: meeting\ndate: 2026-03-25T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions: []\nintents: []\n---\n\n## Transcript\n\nAnother follow-up.\n",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let report = consistency_report(&config, None, 7).unwrap();
assert_eq!(report.decision_conflicts.len(), 1);
assert_eq!(report.decision_conflicts[0].topic, "pricing");
assert_eq!(report.decision_conflicts[0].previous.len(), 1);
assert_eq!(report.stale_commitments.len(), 1);
assert_eq!(
report.stale_commitments[0].entry.who.as_deref(),
Some("case")
);
assert!(report.stale_commitments[0].meetings_since >= 3);
assert!(report.stale_commitments[0]
.reasons
.iter()
.any(|reason| reason.contains("days old")));
assert!(report.stale_commitments[0]
.reasons
.iter()
.any(|reason| reason.contains("newer meetings since")));
assert!(report.stale_commitments[0]
.reasons
.iter()
.any(|reason| reason.contains("still open with due date March 8")));
assert_eq!(
report.stale_commitments[0]
.latest_follow_up
.as_ref()
.map(|meeting| meeting.title.as_str()),
Some("Another Follow-up")
);
}
#[test]
fn consistency_report_resolves_stale_owner_through_speaker_overlay() {
let dir = TempDir::new().unwrap();
let meeting = dir.path().join("2020-03-01-a.md");
create_test_file(
dir.path(),
"2020-03-01-a.md",
"---\ntitle: Follow-up Owner\ntype: meeting\ndate: 2020-03-01T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions: []\nspeaker_map:\n - speaker_label: SPEAKER_0\n name: Unknown Speaker\n confidence: medium\n source: llm\nintents:\n - kind: commitment\n what: Send the rollout memo\n who: SPEAKER_0\n status: open\n by_date: March 8\n---\n\n## Transcript\n\n[SPEAKER_0 0:00] I'll send it.\n",
);
let overlay_db = dir.path().join("overlays.db");
crate::overlays::write_speaker_confirmation_at(
&overlay_db,
&meeting,
"SPEAKER_0",
"Alex Kim",
Some("Unknown Speaker"),
Some("test consistency owner resolution"),
)
.unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let report = consistency_report_at(&config, Some("alex"), 7, &overlay_db).unwrap();
assert_eq!(report.stale_commitments.len(), 1);
let entry = &report.stale_commitments[0].entry;
assert_eq!(entry.who.as_deref(), Some("Alex Kim"));
assert_eq!(entry.who_original.as_deref(), Some("SPEAKER_0"));
assert_eq!(entry.who_provenance.as_deref(), Some("speaker overlay"));
}
#[test]
fn consistency_report_marks_conflict_resolved_when_supersedes_is_set() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-02-28-a.md",
"---\ntitle: Pricing Strategy\ntype: meeting\ndate: 2026-02-28T10:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Launch monthly billing for consultants\n topic: pricing\n authority: high\nintents: []\n---\n\n## Transcript\n\nDecision A.\n",
);
create_test_file(
dir.path(),
"2026-03-25-b.md",
"---\ntitle: Pricing Reversal\ntype: meeting\ndate: 2026-03-25T10:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Revert to annual-only billing across all segments\n topic: pricing\n authority: high\n supersedes: \"2026-02-28 monthly billing decision\"\nintents: []\n---\n\n## Transcript\n\nDecision B reverses A.\n",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let report = consistency_report(&config, None, 7).unwrap();
assert_eq!(report.decision_conflicts.len(), 1);
let conflict = &report.decision_conflicts[0];
assert_eq!(conflict.topic, "pricing");
assert!(conflict.resolution.is_some());
assert!(conflict.resolution.as_ref().unwrap().contains("2026-02-28"));
assert_eq!(conflict.latest.authority.as_deref(), Some("high"));
assert_eq!(conflict.previous[0].authority.as_deref(), Some("high"));
}
#[test]
fn consistency_report_leaves_resolution_none_without_supersedes() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-01-a.md",
"---\ntitle: A\ntype: meeting\ndate: 2026-03-01T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Launch monthly billing\n topic: pricing\nintents: []\n---\n\n## Transcript\n\nA.\n",
);
create_test_file(
dir.path(),
"2026-03-12-b.md",
"---\ntitle: B\ntype: meeting\ndate: 2026-03-12T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Stay on annual billing\n topic: pricing\nintents: []\n---\n\n## Transcript\n\nB without supersedes.\n",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let report = consistency_report(&config, None, 7).unwrap();
assert_eq!(report.decision_conflicts.len(), 1);
assert!(report.decision_conflicts[0].resolution.is_none());
assert!(report.decision_conflicts[0].latest.authority.is_none());
}
#[test]
fn consistency_report_does_not_mark_resolution_when_other_conflicts_remain() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-01-a.md",
"---\ntitle: A\ntype: meeting\ndate: 2026-03-01T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Launch monthly billing\n topic: pricing\nintents: []\n---\n\n## Transcript\n\nA.\n",
);
create_test_file(
dir.path(),
"2026-03-12-b.md",
"---\ntitle: B\ntype: meeting\ndate: 2026-03-12T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Stay annual only\n topic: pricing\nintents: []\n---\n\n## Transcript\n\nB.\n",
);
create_test_file(
dir.path(),
"2026-03-25-c.md",
"---\ntitle: C\ntype: meeting\ndate: 2026-03-25T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Test monthly billing for consultants only\n topic: pricing\n supersedes: \"2026-03-01 monthly billing decision\"\nintents: []\n---\n\n## Transcript\n\nC.\n",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let report = consistency_report(&config, None, 7).unwrap();
assert_eq!(report.decision_conflicts.len(), 1);
let conflict = &report.decision_conflicts[0];
assert_eq!(conflict.previous.len(), 2);
assert!(conflict.resolution.is_none());
}
#[test]
fn consistency_report_requires_supersedes_to_reference_the_prior_decision() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-01-a.md",
"---\ntitle: A\ntype: meeting\ndate: 2026-03-01T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Launch monthly billing\n topic: pricing\nintents: []\n---\n\n## Transcript\n\nA.\n",
);
create_test_file(
dir.path(),
"2026-03-12-b.md",
"---\ntitle: B\ntype: meeting\ndate: 2026-03-12T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Stay annual only\n topic: pricing\n supersedes: \"some old plan\"\nintents: []\n---\n\n## Transcript\n\nB.\n",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let report = consistency_report(&config, None, 7).unwrap();
assert_eq!(report.decision_conflicts.len(), 1);
assert!(report.decision_conflicts[0].resolution.is_none());
}
#[test]
fn consistency_report_ignores_near_duplicate_decisions() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-01-a.md",
"---\ntitle: Pricing Decision\ntype: meeting\ndate: 2026-03-01T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Launch pricing at 399 per month\n topic: pricing strategy\nintents: []\n---\n\n## Transcript\n\nPricing discussion.\n",
);
create_test_file(
dir.path(),
"2026-03-12-b.md",
"---\ntitle: Pricing Follow-up\ntype: meeting\ndate: 2026-03-12T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions:\n - text: Launch pricing at 399 per month.\n topic: pricing strategy\nintents: []\n---\n\n## Transcript\n\nPricing repeated.\n",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let report = consistency_report(&config, None, 7).unwrap();
assert!(report.decision_conflicts.is_empty());
}
#[test]
fn person_profile_aggregates_recent_meetings_topics_and_open_intents() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-17-a.md",
"---\ntitle: Pricing Review\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 42m\nstatus: complete\ntags: []\nattendees: [Alex]\npeople: []\naction_items: []\ndecisions:\n - text: Launch pricing at monthly billing per month\n topic: pricing\nintents:\n - kind: commitment\n what: Share revised pricing model\n who: Alex\n status: open\n by_date: Tuesday\n---\n\n## Transcript\n\nWe discussed pricing.\n",
);
create_test_file(
dir.path(),
"2026-03-20-b.md",
"---\ntitle: Onboarding Follow-up\ntype: meeting\ndate: 2026-03-20T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: [Alex]\npeople: []\naction_items: []\ndecisions: []\nintents:\n - kind: action-item\n what: Review onboarding copy\n who: Alex\n status: open\n by_date: Friday\n---\n\n## Transcript\n\nWe discussed onboarding.\n",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let profile = person_profile(&config, "alex").unwrap();
assert_eq!(profile.name, "alex");
assert_eq!(profile.recent_meetings.len(), 2);
assert_eq!(profile.open_intents.len(), 2);
assert_eq!(profile.recent_decisions.len(), 1);
assert!(profile
.top_topics
.iter()
.any(|topic| topic.topic == "pricing"));
}
#[test]
fn person_profile_matches_linked_people_entities() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-17-a.md",
"---\ntitle: Pricing Review\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 42m\nstatus: complete\ntags: []\nattendees: []\npeople: [Alex Chen]\nentities:\n people:\n - slug: sarah-chen\n label: Alex Chen\n aliases: [sarah]\n projects:\n - slug: pricing-review\n label: Pricing Review\n aliases: [pricing]\naction_items: []\ndecisions:\n - text: Launch pricing at monthly billing per month\n topic: pricing\nintents: []\n---\n\n## Transcript\n\nWe discussed pricing.\n",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let profile = person_profile(&config, "sarah").unwrap();
assert_eq!(profile.recent_meetings.len(), 1);
assert_eq!(profile.recent_meetings[0].title, "Pricing Review");
}
#[test]
fn cross_meeting_research_collects_decisions_intents_and_meetings() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-17-a.md",
"---\ntitle: Pricing Review\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 42m\nstatus: complete\ntags: []\nattendees: [Alex]\npeople: [Alex]\nentities:\n people:\n - slug: sarah\n label: Alex\n aliases: []\n projects:\n - slug: pricing\n label: Pricing\n aliases: []\ncontext: pricing review\naction_items: []\ndecisions:\n - text: Launch pricing at monthly billing per month\n topic: pricing\nintents:\n - kind: commitment\n what: Share revised pricing model\n who: Alex\n status: open\n by_date: Tuesday\n---\n\n## Transcript\n\nWe discussed pricing.\n",
);
create_test_file(
dir.path(),
"2026-03-20-b.md",
"---\ntitle: Onboarding Follow-up\ntype: meeting\ndate: 2026-03-20T12:00:00-07:00\nduration: 30m\nstatus: complete\ntags: []\nattendees: []\npeople: []\naction_items: []\ndecisions: []\nintents: []\n---\n\n## Transcript\n\nWe discussed onboarding.\n",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let filters = SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: None,
owner: None,
recorded_by: None,
};
let report = cross_meeting_research("pricing", &config, &filters).unwrap();
assert_eq!(report.related_decisions.len(), 1);
assert_eq!(report.related_open_intents.len(), 1);
assert_eq!(report.recent_meetings.len(), 1);
assert_eq!(report.recent_meetings[0].title, "Pricing Review");
assert!(report
.related_topics
.iter()
.any(|topic| topic.topic == "pricing"));
}
#[test]
fn find_open_actions_parses_frontmatter() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"2026-03-17-test.md",
"---\ntitle: Test\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 5m\nstatus: complete\naction_items:\n - assignee: mat\n task: Send doc\n status: open\n - assignee: alex\n task: Review PR\n status: done\ndecisions: []\nintents: []\n---\n\nTranscript\n",
);
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let results = find_open_actions(&config, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].assignee, "mat");
assert_eq!(results[0].task, "Send doc");
// Filter by assignee
let filtered = find_open_actions(&config, Some("nobody")).unwrap();
assert!(filtered.is_empty());
}
}