use crate::config::{AppConfig, ProjectConfig, load_from_path};
use crate::domain::{
ContextBundle, DebugTrace, OutputFormat, RouteInput, WakeupPacket, WakeupProfile,
};
use crate::lifecycle_store::lifecycle_root_from_config;
use crate::vault::{RoutedSnapshot, WakeupSnapshot};
use crate::{engine, output, vault, wakeup};
use std::path::{Path, PathBuf};
fn developer_scored_note_limit(config: &AppConfig) -> usize {
config.output.max_notes.max(12)
}
fn wakeup_scored_note_limit(config: &AppConfig, profile: WakeupProfile) -> usize {
match profile {
WakeupProfile::Developer => developer_scored_note_limit(config),
WakeupProfile::Project => config.output.max_notes.max(8),
}
}
fn build_wakeup_debug(config: &AppConfig, wakeup_snapshot: &WakeupSnapshot) -> DebugTrace {
DebugTrace {
matched_project_id: wakeup_snapshot.project_id.clone(),
note_roots: wakeup_snapshot.note_roots.clone(),
scan_roots: wakeup_snapshot.snapshot.scan_roots.clone(),
limits: config.vault.limits.clone(),
note_count: wakeup_snapshot.snapshot.notes.len(),
}
}
fn find_project_config<'a>(
config: &'a AppConfig,
project_id: Option<&str>,
) -> Option<&'a ProjectConfig> {
project_id.and_then(|project_id| {
config
.projects
.iter()
.find(|project| project.id == project_id)
})
}
fn build_developer_note_roots(
config: &AppConfig,
matched_project: Option<&ProjectConfig>,
) -> Vec<String> {
config.developer.effective_note_roots(
matched_project
.map(|project| project.note_roots.as_slice())
.unwrap_or(&[]),
)
}
fn build_wakeup_scored_notes(
config: &AppConfig,
bundle: &ContextBundle,
matched_project: Option<&ProjectConfig>,
wakeup_snapshot: &WakeupSnapshot,
input: &RouteInput,
profile: WakeupProfile,
) -> Vec<crate::domain::ScoredNote> {
engine::selector::select_scored_notes(
matched_project,
bundle.route.project.as_ref(),
&bundle.route.modules,
&bundle.route.scenes,
&wakeup_snapshot.snapshot.notes,
input,
wakeup_scored_note_limit(config, profile),
)
}
fn build_wakeup_packet(
bundle: &ContextBundle,
scored_notes: &[crate::domain::ScoredNote],
matched_project: Option<&ProjectConfig>,
config: &AppConfig,
profile: WakeupProfile,
lifecycle_root: Option<&Path>,
) -> WakeupPacket {
let knowledge_index = load_wakeup_index(bundle, config);
wakeup::build_packet_with_index(
bundle,
scored_notes,
matched_project,
&config.developer.note_roots,
profile,
knowledge_index,
lifecycle_root,
)
}
fn load_wakeup_index(bundle: &ContextBundle, config: &AppConfig) -> Option<String> {
let vault_root = config.vault.root.as_path();
let current_project_id = bundle
.route
.project
.as_ref()
.map(|project| project.id.as_str());
crate::wiki_index::load_index_section(vault_root, current_project_id)
}
fn build_project_wakeup_snapshot(config: &AppConfig, cwd: &Path) -> anyhow::Result<WakeupSnapshot> {
let project_config = require_project_config(config, cwd)?;
let note_roots = project_config.note_roots.clone();
let snapshot = vault::cached_scan_notes_with_debug(
&config.vault.root,
note_roots.as_slice(),
&config.vault.limits,
)?;
Ok(WakeupSnapshot {
project_id: Some(project_config.id.clone()),
note_roots,
snapshot,
})
}
fn build_developer_wakeup_snapshot(
config: &AppConfig,
cwd: &Path,
) -> anyhow::Result<WakeupSnapshot> {
let matched_project = engine::project_config_for_input(config, cwd);
let note_roots = build_developer_note_roots(config, matched_project);
if note_roots.is_empty() {
anyhow::bail!("developer wakeup has no note_roots configured");
}
let snapshot = vault::cached_scan_notes_with_debug(
&config.vault.root,
note_roots.as_slice(),
&config.vault.limits,
)?;
Ok(WakeupSnapshot {
project_id: matched_project.map(|project| project.id.clone()),
note_roots,
snapshot,
})
}
fn resolve_wakeup_snapshot(
config: &AppConfig,
cwd: &Path,
profile: WakeupProfile,
) -> anyhow::Result<WakeupSnapshot> {
match profile {
WakeupProfile::Project => build_project_wakeup_snapshot(config, cwd),
WakeupProfile::Developer => build_developer_wakeup_snapshot(config, cwd),
}
}
fn build_wakeup_bundle(
config: &AppConfig,
wakeup_snapshot: &WakeupSnapshot,
input: RouteInput,
) -> ContextBundle {
let debug = build_wakeup_debug(config, wakeup_snapshot);
engine::build_context(config, &wakeup_snapshot.snapshot.notes, input, debug)
}
fn matched_project_for_wakeup<'a>(
config: &'a AppConfig,
wakeup_snapshot: &WakeupSnapshot,
) -> Option<&'a ProjectConfig> {
find_project_config(config, wakeup_snapshot.project_id.as_deref())
}
fn build_developer_packet(
config: &AppConfig,
wakeup_snapshot: &WakeupSnapshot,
bundle: &ContextBundle,
input: &RouteInput,
lifecycle_root: Option<&Path>,
) -> WakeupPacket {
let matched_project = matched_project_for_wakeup(config, wakeup_snapshot);
let scored_notes = build_wakeup_scored_notes(
config,
bundle,
matched_project,
wakeup_snapshot,
input,
WakeupProfile::Developer,
);
build_wakeup_packet(
bundle,
&scored_notes,
matched_project,
config,
WakeupProfile::Developer,
lifecycle_root,
)
}
fn build_project_packet(
config: &AppConfig,
wakeup_snapshot: &WakeupSnapshot,
bundle: &ContextBundle,
input: &RouteInput,
lifecycle_root: Option<&Path>,
) -> WakeupPacket {
let matched_project = matched_project_for_wakeup(config, wakeup_snapshot);
let scored_notes = build_wakeup_scored_notes(
config,
bundle,
matched_project,
wakeup_snapshot,
input,
WakeupProfile::Project,
);
build_wakeup_packet(
bundle,
&scored_notes,
matched_project,
config,
WakeupProfile::Project,
lifecycle_root,
)
}
fn ensure_wakeup_contract(
config: &AppConfig,
wakeup_snapshot: &WakeupSnapshot,
profile: WakeupProfile,
) -> anyhow::Result<()> {
if wakeup_snapshot.note_roots.is_empty() {
anyhow::bail!("wakeup profile has no note_roots configured");
}
if matches!(profile, WakeupProfile::Project) && wakeup_snapshot.project_id.is_none() {
anyhow::bail!("project wakeup requires a matched project");
}
if matches!(profile, WakeupProfile::Developer)
&& config.developer.effective_note_roots(&[]).is_empty()
{
anyhow::bail!("developer wakeup requires developer note_roots");
}
Ok(())
}
fn build_packet_from_profile(
config: &AppConfig,
wakeup_snapshot: &WakeupSnapshot,
bundle: &ContextBundle,
input: &RouteInput,
profile: WakeupProfile,
lifecycle_root: Option<&Path>,
) -> WakeupPacket {
match profile {
WakeupProfile::Developer => {
build_developer_packet(config, wakeup_snapshot, bundle, input, lifecycle_root)
}
WakeupProfile::Project => {
build_project_packet(config, wakeup_snapshot, bundle, input, lifecycle_root)
}
}
}
fn build_wakeup_input(mut input: RouteInput) -> RouteInput {
input.format = OutputFormat::Json;
input
}
fn resolve_wakeup_profile(profile: WakeupProfile) -> WakeupProfile {
profile
}
fn wakeup_bundle_and_snapshot(
config: &AppConfig,
input: RouteInput,
profile: WakeupProfile,
) -> anyhow::Result<(WakeupSnapshot, ContextBundle)> {
let wakeup_snapshot = resolve_wakeup_snapshot(config, &input.cwd, profile)?;
ensure_wakeup_contract(config, &wakeup_snapshot, profile)?;
let bundle = build_wakeup_bundle(config, &wakeup_snapshot, input);
Ok((wakeup_snapshot, bundle))
}
fn packet_for_profile(
config: &AppConfig,
input: &RouteInput,
wakeup_snapshot: &WakeupSnapshot,
bundle: &ContextBundle,
profile: WakeupProfile,
lifecycle_root: &Path,
) -> WakeupPacket {
build_packet_from_profile(
config,
wakeup_snapshot,
bundle,
input,
profile,
Some(lifecycle_root),
)
}
fn wakeup_config(config_path: &Path) -> anyhow::Result<AppConfig> {
load_from_path(config_path)
}
fn lifecycle_root_for_config(config_path: &Path) -> PathBuf {
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
lifecycle_root_from_config(config_dir)
}
fn wakeup_input(input: RouteInput) -> RouteInput {
build_wakeup_input(input)
}
pub struct AppResult {
pub bundle: ContextBundle,
pub rendered: String,
pub explain: String,
pub used_format: OutputFormat,
pub used_vault_root: PathBuf,
}
pub fn run(
config_path: &Path,
input: RouteInput,
requested_format: Option<OutputFormat>,
) -> anyhow::Result<AppResult> {
run_with_overrides(config_path, input, requested_format, None)
}
pub fn run_with_overrides(
config_path: &Path,
mut input: RouteInput,
requested_format: Option<OutputFormat>,
vault_root_override: Option<&Path>,
) -> anyhow::Result<AppResult> {
let mut config = load_from_path(config_path)?;
if let Some(vault_root_override) = vault_root_override {
config.vault.root = resolve_override_path(vault_root_override, config_path)?;
}
let used_format = requested_format.unwrap_or(config.output.default_format);
input.format = used_format;
let bundle = build_bundle(&config, input)?;
let rendered = output::render(&bundle, config.output.max_chars, used_format);
let explain = output::explain(&bundle);
Ok(AppResult {
bundle,
rendered,
explain,
used_format,
used_vault_root: config.vault.root.clone(),
})
}
pub(crate) fn resolve_override_path(
override_path: &Path,
config_path: &Path,
) -> anyhow::Result<PathBuf> {
let candidate = if override_path.is_absolute() {
override_path.to_path_buf()
} else {
let base_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
base_dir.join(override_path)
};
Ok(candidate
.canonicalize()
.unwrap_or_else(|_| normalize_absolute_path(&candidate)))
}
fn normalize_absolute_path(path: &Path) -> PathBuf {
use std::path::Component;
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
pub fn load(config_path: &Path) -> anyhow::Result<AppConfig> {
load_from_path(config_path)
}
pub fn build_bundle(config: &AppConfig, input: RouteInput) -> anyhow::Result<ContextBundle> {
build_bundle_with_lifecycle(config, input, &[])
}
pub fn build_bundle_with_lifecycle(
config: &AppConfig,
input: RouteInput,
lifecycle_records: &[(String, crate::domain::MemoryRecord)],
) -> anyhow::Result<ContextBundle> {
build_bundle_with_lifecycle_and_refs(config, input, lifecycle_records, None)
}
pub fn build_bundle_with_lifecycle_and_refs(
config: &AppConfig,
input: RouteInput,
lifecycle_records: &[(String, crate::domain::MemoryRecord)],
reference_map: Option<&crate::reference_tracker::ReferenceMap>,
) -> anyhow::Result<ContextBundle> {
let routed = build_routed_snapshot(config, &input.cwd)?;
let debug = DebugTrace {
matched_project_id: Some(routed.project_id),
note_roots: routed.note_roots,
scan_roots: routed.snapshot.scan_roots.clone(),
limits: config.vault.limits.clone(),
note_count: routed.snapshot.notes.len(),
};
Ok(engine::build_context_with_lifecycle_and_refs(
config,
&routed.snapshot.notes,
lifecycle_records,
input,
debug,
reference_map,
))
}
pub fn render(config: &AppConfig, bundle: &ContextBundle, format: OutputFormat) -> String {
output::render(bundle, config.output.max_chars, format)
}
pub fn build_wakeup(
config_path: &Path,
input: RouteInput,
profile: WakeupProfile,
) -> anyhow::Result<WakeupPacket> {
let config = wakeup_config(config_path)?;
let profile = resolve_wakeup_profile(profile);
let input = wakeup_input(input);
let (wakeup_snapshot, bundle) = wakeup_bundle_and_snapshot(&config, input.clone(), profile)?;
let lifecycle_root = lifecycle_root_for_config(config_path);
Ok(packet_for_profile(
&config,
&input,
&wakeup_snapshot,
&bundle,
profile,
&lifecycle_root,
))
}
pub fn explain(bundle: &ContextBundle) -> String {
output::explain(bundle)
}
pub fn resolve_format(config: &AppConfig, requested_format: Option<OutputFormat>) -> OutputFormat {
requested_format.unwrap_or(config.output.default_format)
}
fn require_project_config<'a>(
config: &'a AppConfig,
cwd: &Path,
) -> anyhow::Result<&'a ProjectConfig> {
let project = engine::project_config_for_input(config, cwd)
.ok_or_else(|| anyhow::anyhow!("no project matched cwd: {}", cwd.display()))?;
if project.note_roots.is_empty() {
anyhow::bail!(
"matched project has no note_roots configured: {}",
project.id
);
}
Ok(project)
}
fn build_routed_snapshot(config: &AppConfig, cwd: &Path) -> anyhow::Result<RoutedSnapshot> {
let project_config = require_project_config(config, cwd)?;
let note_roots = project_config.note_roots.clone();
let snapshot = vault::cached_scan_notes_with_debug(
&config.vault.root,
note_roots.as_slice(),
&config.vault.limits,
)?;
Ok(RoutedSnapshot {
project_id: project_config.id.clone(),
note_roots,
snapshot,
})
}
#[cfg(test)]
mod tests {
use super::{resolve_format, resolve_override_path};
use crate::config::{
AppConfig, DeveloperConfig, EmbeddingConfig, OutputConfig, SceneConfig, VaultConfig,
VaultLimits,
};
use crate::domain::OutputFormat;
use std::path::{Path, PathBuf};
#[test]
fn resolve_format_falls_back_to_config_default() {
let config = AppConfig {
vault: VaultConfig {
root: PathBuf::from("/tmp"),
limits: VaultLimits::default(),
},
output: OutputConfig {
default_format: OutputFormat::Json,
max_chars: 100,
max_notes: 3,
max_lifecycle: 5,
},
developer: DeveloperConfig::default(),
projects: Vec::new(),
scenes: Vec::<SceneConfig>::new(),
embedding: EmbeddingConfig::default(),
};
assert_eq!(resolve_format(&config, None), OutputFormat::Json);
assert_eq!(
resolve_format(&config, Some(OutputFormat::Prompt)),
OutputFormat::Prompt
);
}
#[test]
fn resolve_override_path_against_config_dir() {
let resolved = resolve_override_path(
Path::new("../vault-dev"),
Path::new("/tmp/example/config/spool.toml"),
)
.unwrap();
assert_eq!(resolved, Path::new("/tmp/example/vault-dev"));
}
}