1use crate::config::{AppConfig, ProjectConfig, load_from_path};
2use crate::domain::{
3 ContextBundle, DebugTrace, OutputFormat, RouteInput, WakeupPacket, WakeupProfile,
4};
5use crate::lifecycle_store::lifecycle_root_from_config;
6use crate::vault::{RoutedSnapshot, WakeupSnapshot};
7use crate::{engine, output, vault, wakeup};
8use std::path::{Path, PathBuf};
9
10fn developer_scored_note_limit(config: &AppConfig) -> usize {
11 config.output.max_notes.max(12)
12}
13
14fn wakeup_scored_note_limit(config: &AppConfig, profile: WakeupProfile) -> usize {
15 match profile {
16 WakeupProfile::Developer => developer_scored_note_limit(config),
17 WakeupProfile::Project => config.output.max_notes.max(8),
18 }
19}
20
21fn build_wakeup_debug(config: &AppConfig, wakeup_snapshot: &WakeupSnapshot) -> DebugTrace {
22 DebugTrace {
23 matched_project_id: wakeup_snapshot.project_id.clone(),
24 note_roots: wakeup_snapshot.note_roots.clone(),
25 scan_roots: wakeup_snapshot.snapshot.scan_roots.clone(),
26 limits: config.vault.limits.clone(),
27 note_count: wakeup_snapshot.snapshot.notes.len(),
28 }
29}
30
31fn find_project_config<'a>(
32 config: &'a AppConfig,
33 project_id: Option<&str>,
34) -> Option<&'a ProjectConfig> {
35 project_id.and_then(|project_id| {
36 config
37 .projects
38 .iter()
39 .find(|project| project.id == project_id)
40 })
41}
42
43fn build_developer_note_roots(
44 config: &AppConfig,
45 matched_project: Option<&ProjectConfig>,
46) -> Vec<String> {
47 config.developer.effective_note_roots(
48 matched_project
49 .map(|project| project.note_roots.as_slice())
50 .unwrap_or(&[]),
51 )
52}
53
54fn build_wakeup_scored_notes(
55 config: &AppConfig,
56 bundle: &ContextBundle,
57 matched_project: Option<&ProjectConfig>,
58 wakeup_snapshot: &WakeupSnapshot,
59 input: &RouteInput,
60 profile: WakeupProfile,
61) -> Vec<crate::domain::ScoredNote> {
62 engine::selector::select_scored_notes(
63 matched_project,
64 bundle.route.project.as_ref(),
65 &bundle.route.modules,
66 &bundle.route.scenes,
67 &wakeup_snapshot.snapshot.notes,
68 input,
69 wakeup_scored_note_limit(config, profile),
70 )
71}
72
73fn build_wakeup_packet(
74 bundle: &ContextBundle,
75 scored_notes: &[crate::domain::ScoredNote],
76 matched_project: Option<&ProjectConfig>,
77 config: &AppConfig,
78 profile: WakeupProfile,
79 lifecycle_root: Option<&Path>,
80) -> WakeupPacket {
81 let knowledge_index = load_wakeup_index(bundle, config);
82 wakeup::build_packet_with_index(
83 bundle,
84 scored_notes,
85 matched_project,
86 &config.developer.note_roots,
87 profile,
88 knowledge_index,
89 lifecycle_root,
90 )
91}
92
93fn load_wakeup_index(bundle: &ContextBundle, config: &AppConfig) -> Option<String> {
94 let vault_root = config.vault.root.as_path();
95 let current_project_id = bundle
96 .route
97 .project
98 .as_ref()
99 .map(|project| project.id.as_str());
100 crate::wiki_index::load_index_section(vault_root, current_project_id)
101}
102
103fn build_project_wakeup_snapshot(config: &AppConfig, cwd: &Path) -> anyhow::Result<WakeupSnapshot> {
104 let project_config = require_project_config(config, cwd)?;
105 let note_roots = project_config.note_roots.clone();
106 let snapshot = vault::cached_scan_notes_with_debug(
107 &config.vault.root,
108 note_roots.as_slice(),
109 &config.vault.limits,
110 )?;
111
112 Ok(WakeupSnapshot {
113 project_id: Some(project_config.id.clone()),
114 note_roots,
115 snapshot,
116 })
117}
118
119fn build_developer_wakeup_snapshot(
120 config: &AppConfig,
121 cwd: &Path,
122) -> anyhow::Result<WakeupSnapshot> {
123 let matched_project = engine::project_config_for_input(config, cwd);
124 let note_roots = build_developer_note_roots(config, matched_project);
125 if note_roots.is_empty() {
126 anyhow::bail!("developer wakeup has no note_roots configured");
127 }
128 let snapshot = vault::cached_scan_notes_with_debug(
129 &config.vault.root,
130 note_roots.as_slice(),
131 &config.vault.limits,
132 )?;
133
134 Ok(WakeupSnapshot {
135 project_id: matched_project.map(|project| project.id.clone()),
136 note_roots,
137 snapshot,
138 })
139}
140
141fn resolve_wakeup_snapshot(
142 config: &AppConfig,
143 cwd: &Path,
144 profile: WakeupProfile,
145) -> anyhow::Result<WakeupSnapshot> {
146 match profile {
147 WakeupProfile::Project => build_project_wakeup_snapshot(config, cwd),
148 WakeupProfile::Developer => build_developer_wakeup_snapshot(config, cwd),
149 }
150}
151
152fn build_wakeup_bundle(
153 config: &AppConfig,
154 wakeup_snapshot: &WakeupSnapshot,
155 input: RouteInput,
156) -> ContextBundle {
157 let debug = build_wakeup_debug(config, wakeup_snapshot);
158 engine::build_context(config, &wakeup_snapshot.snapshot.notes, input, debug)
159}
160
161fn matched_project_for_wakeup<'a>(
162 config: &'a AppConfig,
163 wakeup_snapshot: &WakeupSnapshot,
164) -> Option<&'a ProjectConfig> {
165 find_project_config(config, wakeup_snapshot.project_id.as_deref())
166}
167
168fn build_developer_packet(
169 config: &AppConfig,
170 wakeup_snapshot: &WakeupSnapshot,
171 bundle: &ContextBundle,
172 input: &RouteInput,
173 lifecycle_root: Option<&Path>,
174) -> WakeupPacket {
175 let matched_project = matched_project_for_wakeup(config, wakeup_snapshot);
176 let scored_notes = build_wakeup_scored_notes(
177 config,
178 bundle,
179 matched_project,
180 wakeup_snapshot,
181 input,
182 WakeupProfile::Developer,
183 );
184 build_wakeup_packet(
185 bundle,
186 &scored_notes,
187 matched_project,
188 config,
189 WakeupProfile::Developer,
190 lifecycle_root,
191 )
192}
193
194fn build_project_packet(
195 config: &AppConfig,
196 wakeup_snapshot: &WakeupSnapshot,
197 bundle: &ContextBundle,
198 input: &RouteInput,
199 lifecycle_root: Option<&Path>,
200) -> WakeupPacket {
201 let matched_project = matched_project_for_wakeup(config, wakeup_snapshot);
202 let scored_notes = build_wakeup_scored_notes(
203 config,
204 bundle,
205 matched_project,
206 wakeup_snapshot,
207 input,
208 WakeupProfile::Project,
209 );
210 build_wakeup_packet(
211 bundle,
212 &scored_notes,
213 matched_project,
214 config,
215 WakeupProfile::Project,
216 lifecycle_root,
217 )
218}
219
220fn ensure_wakeup_contract(
221 config: &AppConfig,
222 wakeup_snapshot: &WakeupSnapshot,
223 profile: WakeupProfile,
224) -> anyhow::Result<()> {
225 if wakeup_snapshot.note_roots.is_empty() {
226 anyhow::bail!("wakeup profile has no note_roots configured");
227 }
228 if matches!(profile, WakeupProfile::Project) && wakeup_snapshot.project_id.is_none() {
229 anyhow::bail!("project wakeup requires a matched project");
230 }
231 if matches!(profile, WakeupProfile::Developer)
232 && config.developer.effective_note_roots(&[]).is_empty()
233 {
234 anyhow::bail!("developer wakeup requires developer note_roots");
235 }
236 Ok(())
237}
238
239fn build_packet_from_profile(
240 config: &AppConfig,
241 wakeup_snapshot: &WakeupSnapshot,
242 bundle: &ContextBundle,
243 input: &RouteInput,
244 profile: WakeupProfile,
245 lifecycle_root: Option<&Path>,
246) -> WakeupPacket {
247 match profile {
248 WakeupProfile::Developer => {
249 build_developer_packet(config, wakeup_snapshot, bundle, input, lifecycle_root)
250 }
251 WakeupProfile::Project => {
252 build_project_packet(config, wakeup_snapshot, bundle, input, lifecycle_root)
253 }
254 }
255}
256
257fn build_wakeup_input(mut input: RouteInput) -> RouteInput {
258 input.format = OutputFormat::Json;
259 input
260}
261
262fn resolve_wakeup_profile(profile: WakeupProfile) -> WakeupProfile {
263 profile
264}
265
266fn wakeup_bundle_and_snapshot(
267 config: &AppConfig,
268 input: RouteInput,
269 profile: WakeupProfile,
270) -> anyhow::Result<(WakeupSnapshot, ContextBundle)> {
271 let wakeup_snapshot = resolve_wakeup_snapshot(config, &input.cwd, profile)?;
272 ensure_wakeup_contract(config, &wakeup_snapshot, profile)?;
273 let bundle = build_wakeup_bundle(config, &wakeup_snapshot, input);
274 Ok((wakeup_snapshot, bundle))
275}
276
277fn packet_for_profile(
278 config: &AppConfig,
279 input: &RouteInput,
280 wakeup_snapshot: &WakeupSnapshot,
281 bundle: &ContextBundle,
282 profile: WakeupProfile,
283 lifecycle_root: &Path,
284) -> WakeupPacket {
285 build_packet_from_profile(
286 config,
287 wakeup_snapshot,
288 bundle,
289 input,
290 profile,
291 Some(lifecycle_root),
292 )
293}
294
295fn wakeup_config(config_path: &Path) -> anyhow::Result<AppConfig> {
296 load_from_path(config_path)
297}
298
299fn lifecycle_root_for_config(config_path: &Path) -> PathBuf {
300 let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
301 lifecycle_root_from_config(config_dir)
302}
303
304fn wakeup_input(input: RouteInput) -> RouteInput {
305 build_wakeup_input(input)
306}
307
308pub struct AppResult {
309 pub bundle: ContextBundle,
310 pub rendered: String,
311 pub explain: String,
312 pub used_format: OutputFormat,
313 pub used_vault_root: PathBuf,
314}
315
316pub fn run(
317 config_path: &Path,
318 input: RouteInput,
319 requested_format: Option<OutputFormat>,
320) -> anyhow::Result<AppResult> {
321 run_with_overrides(config_path, input, requested_format, None)
322}
323
324pub fn run_with_overrides(
325 config_path: &Path,
326 mut input: RouteInput,
327 requested_format: Option<OutputFormat>,
328 vault_root_override: Option<&Path>,
329) -> anyhow::Result<AppResult> {
330 let mut config = load_from_path(config_path)?;
331 if let Some(vault_root_override) = vault_root_override {
332 config.vault.root = resolve_override_path(vault_root_override, config_path)?;
333 }
334 let used_format = requested_format.unwrap_or(config.output.default_format);
335 input.format = used_format;
336
337 let bundle = build_bundle(&config, input)?;
338 let rendered = output::render(&bundle, config.output.max_chars, used_format);
339 let explain = output::explain(&bundle);
340
341 Ok(AppResult {
342 bundle,
343 rendered,
344 explain,
345 used_format,
346 used_vault_root: config.vault.root.clone(),
347 })
348}
349
350pub(crate) fn resolve_override_path(
351 override_path: &Path,
352 config_path: &Path,
353) -> anyhow::Result<PathBuf> {
354 let candidate = if override_path.is_absolute() {
355 override_path.to_path_buf()
356 } else {
357 let base_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
358 base_dir.join(override_path)
359 };
360 Ok(candidate
361 .canonicalize()
362 .unwrap_or_else(|_| normalize_absolute_path(&candidate)))
363}
364
365fn normalize_absolute_path(path: &Path) -> PathBuf {
366 use std::path::Component;
367
368 let mut normalized = PathBuf::new();
369 for component in path.components() {
370 match component {
371 Component::CurDir => {}
372 Component::ParentDir => {
373 normalized.pop();
374 }
375 other => normalized.push(other.as_os_str()),
376 }
377 }
378 normalized
379}
380
381pub fn load(config_path: &Path) -> anyhow::Result<AppConfig> {
382 load_from_path(config_path)
383}
384
385pub fn build_bundle(config: &AppConfig, input: RouteInput) -> anyhow::Result<ContextBundle> {
386 build_bundle_with_lifecycle(config, input, &[])
387}
388
389pub fn build_bundle_with_lifecycle(
390 config: &AppConfig,
391 input: RouteInput,
392 lifecycle_records: &[(String, crate::domain::MemoryRecord)],
393) -> anyhow::Result<ContextBundle> {
394 build_bundle_with_lifecycle_and_refs(config, input, lifecycle_records, None)
395}
396
397pub fn build_bundle_with_lifecycle_and_refs(
398 config: &AppConfig,
399 input: RouteInput,
400 lifecycle_records: &[(String, crate::domain::MemoryRecord)],
401 reference_map: Option<&crate::reference_tracker::ReferenceMap>,
402) -> anyhow::Result<ContextBundle> {
403 let routed = build_routed_snapshot(config, &input.cwd)?;
404 let debug = DebugTrace {
405 matched_project_id: Some(routed.project_id),
406 note_roots: routed.note_roots,
407 scan_roots: routed.snapshot.scan_roots.clone(),
408 limits: config.vault.limits.clone(),
409 note_count: routed.snapshot.notes.len(),
410 };
411 Ok(engine::build_context_with_lifecycle_and_refs(
412 config,
413 &routed.snapshot.notes,
414 lifecycle_records,
415 input,
416 debug,
417 reference_map,
418 ))
419}
420
421pub fn render(config: &AppConfig, bundle: &ContextBundle, format: OutputFormat) -> String {
422 output::render(bundle, config.output.max_chars, format)
423}
424
425pub fn build_wakeup(
426 config_path: &Path,
427 input: RouteInput,
428 profile: WakeupProfile,
429) -> anyhow::Result<WakeupPacket> {
430 let config = wakeup_config(config_path)?;
431 let profile = resolve_wakeup_profile(profile);
432 let input = wakeup_input(input);
433 let (wakeup_snapshot, bundle) = wakeup_bundle_and_snapshot(&config, input.clone(), profile)?;
434 let lifecycle_root = lifecycle_root_for_config(config_path);
435 Ok(packet_for_profile(
436 &config,
437 &input,
438 &wakeup_snapshot,
439 &bundle,
440 profile,
441 &lifecycle_root,
442 ))
443}
444
445pub fn explain(bundle: &ContextBundle) -> String {
446 output::explain(bundle)
447}
448
449pub fn resolve_format(config: &AppConfig, requested_format: Option<OutputFormat>) -> OutputFormat {
450 requested_format.unwrap_or(config.output.default_format)
451}
452
453fn require_project_config<'a>(
454 config: &'a AppConfig,
455 cwd: &Path,
456) -> anyhow::Result<&'a ProjectConfig> {
457 let project = engine::project_config_for_input(config, cwd)
458 .ok_or_else(|| anyhow::anyhow!("no project matched cwd: {}", cwd.display()))?;
459 if project.note_roots.is_empty() {
460 anyhow::bail!(
461 "matched project has no note_roots configured: {}",
462 project.id
463 );
464 }
465 Ok(project)
466}
467
468fn build_routed_snapshot(config: &AppConfig, cwd: &Path) -> anyhow::Result<RoutedSnapshot> {
469 let project_config = require_project_config(config, cwd)?;
470 let note_roots = project_config.note_roots.clone();
471 let snapshot = vault::cached_scan_notes_with_debug(
472 &config.vault.root,
473 note_roots.as_slice(),
474 &config.vault.limits,
475 )?;
476
477 Ok(RoutedSnapshot {
478 project_id: project_config.id.clone(),
479 note_roots,
480 snapshot,
481 })
482}
483
484#[cfg(test)]
485mod tests {
486 use super::{resolve_format, resolve_override_path};
487 use crate::config::{
488 AppConfig, DeveloperConfig, EmbeddingConfig, OutputConfig, SceneConfig, VaultConfig,
489 VaultLimits,
490 };
491 use crate::domain::OutputFormat;
492 use std::path::{Path, PathBuf};
493
494 #[test]
495 fn resolve_format_falls_back_to_config_default() {
496 let config = AppConfig {
497 vault: VaultConfig {
498 root: PathBuf::from("/tmp"),
499 limits: VaultLimits::default(),
500 },
501 output: OutputConfig {
502 default_format: OutputFormat::Json,
503 max_chars: 100,
504 max_notes: 3,
505 max_lifecycle: 5,
506 },
507 developer: DeveloperConfig::default(),
508 projects: Vec::new(),
509 scenes: Vec::<SceneConfig>::new(),
510 embedding: EmbeddingConfig::default(),
511 };
512
513 assert_eq!(resolve_format(&config, None), OutputFormat::Json);
514 assert_eq!(
515 resolve_format(&config, Some(OutputFormat::Prompt)),
516 OutputFormat::Prompt
517 );
518 }
519
520 #[test]
521 fn resolve_override_path_against_config_dir() {
522 let resolved = resolve_override_path(
523 Path::new("../vault-dev"),
524 Path::new("/tmp/example/config/spool.toml"),
525 )
526 .unwrap();
527
528 assert_eq!(resolved, Path::new("/tmp/example/vault-dev"));
529 }
530}