use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use crate::config::{ScopeFilter, TalonConfig};
use crate::constants::RELATED_MAX_DEPTH;
use crate::contracts::{ContainerPath, VaultPath};
use crate::graph::{
GraphRankInput, GraphRankedNode, GraphRelation, GraphSignalBreakdown, load_graph_snapshot,
rank_related,
};
use crate::search::Direction;
mod legacy;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RelatedInput {
pub path: String,
#[serde(default = "default_depth")]
pub depth: u8,
#[serde(default)]
pub direction: Direction,
#[serde(default)]
pub scope: Vec<String>,
#[serde(default)]
pub scope_only: Vec<String>,
#[serde(default)]
pub scope_all: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RelatedResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub vault: Option<ContainerPath>,
pub path: VaultPath,
pub direction: Direction,
pub results: Vec<RelatedResult>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RelatedResult {
pub vault_path: VaultPath,
pub title: String,
pub link_text: String,
pub relation: RelationKind,
pub count: u32,
pub score: f64,
pub signals: GraphSignalBreakdown,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtime: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RelationKind {
Outgoing,
Backlink,
}
const fn default_depth() -> u8 {
crate::constants::RELATED_DEFAULT_DEPTH
}
pub fn find_related(
conn: &Connection,
input: &RelatedInput,
config: Option<&TalonConfig>,
) -> RelatedResponse {
let path = input.path.trim();
let Ok(source_path) = VaultPath::parse(path) else {
return RelatedResponse {
vault: None,
path: VaultPath::parse("_").unwrap_or_else(|_| unreachable!()),
direction: input.direction,
results: Vec::new(),
};
};
let depth = input.depth.clamp(1, RELATED_MAX_DEPTH);
let direction = input.direction;
let filter = config.map(|cfg| {
ScopeFilter::from_args(cfg, &input.scope, &input.scope_only, input.scope_all)
.unwrap_or_else(|_| ScopeFilter::default_for(cfg))
});
if let Ok(snapshot) = load_graph_snapshot(conn)
&& snapshot.nodes.contains_key(path)
&& !snapshot.edges.is_empty()
{
return graph_ranked_response(
conn,
input,
source_path,
depth,
config,
filter.as_ref(),
&snapshot,
);
}
RelatedResponse {
vault: None,
path: source_path,
direction,
results: legacy::legacy_related_results(
conn,
path,
depth,
direction,
filter.as_ref(),
config,
),
}
}
fn graph_ranked_response(
conn: &Connection,
input: &RelatedInput,
source_path: VaultPath,
depth: u8,
config: Option<&TalonConfig>,
filter: Option<&ScopeFilter<'_>>,
snapshot: &crate::graph::GraphSnapshot,
) -> RelatedResponse {
let ranked = rank_related(
snapshot,
&GraphRankInput {
source_path: input.path.trim().to_string(),
direction: input.direction,
depth,
limit: input.limit.unwrap_or(usize::MAX),
scope_priorities: config
.map(|cfg| {
cfg.scopes
.iter()
.map(|(name, scope)| (name.clone(), scope.priority))
.collect()
})
.unwrap_or_default(),
},
);
RelatedResponse {
vault: None,
path: source_path,
direction: input.direction,
results: ranked_to_results(conn, ranked, config, filter),
}
}
fn ranked_to_results(
conn: &Connection,
ranked: Vec<GraphRankedNode>,
config: Option<&TalonConfig>,
filter: Option<&ScopeFilter<'_>>,
) -> Vec<RelatedResult> {
ranked
.into_iter()
.filter(|node| filter.is_none_or(|f| f.accepts(&node.vault_path)))
.filter_map(|node| {
let vault_path = VaultPath::parse(&node.vault_path).ok()?;
let scope = config
.and_then(|cfg| cfg.resolve_scope_name(std::path::Path::new(&node.vault_path)))
.map(str::to_string);
let mtime = super::mtime::local_mtime_for_path(conn, &node.vault_path);
Some(RelatedResult {
vault_path,
title: node.title,
link_text: node.link_text,
relation: match node.relation {
GraphRelation::Outgoing | GraphRelation::Related => RelationKind::Outgoing,
GraphRelation::Backlink => RelationKind::Backlink,
},
count: node.count,
score: node.score,
signals: node.signals,
scope,
mtime,
})
})
.collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests;