Skip to main content

spool/
app.rs

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}