Skip to main content

gobby_code/commands/codewiki/
types.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use gobby_core::config::AiRouting;
4use serde::{Deserialize, Serialize};
5
6use crate::models::Symbol;
7
8use super::prompts;
9
10#[derive(Debug, Clone)]
11pub struct CodewikiInput {
12    pub files: Vec<String>,
13    pub graph_edges: Vec<CodewikiGraphEdge>,
14    pub graph_availability: CodewikiGraphAvailability,
15    pub symbols: Vec<Symbol>,
16    /// Leading content chunk per file, retrieved from the code index. Feeds
17    /// real source content into aggregate prompts and gives non-code files
18    /// (markdown, config) a content-derived purpose. Missing entries degrade
19    /// to summary-only prompts.
20    pub leading_chunks: BTreeMap<String, LeadingChunk>,
21}
22
23/// The first indexed content chunk of a file: real source text with its
24/// line range, used as retrieved prompt input and citation provenance.
25#[derive(Debug, Clone)]
26pub struct LeadingChunk {
27    pub content: String,
28    pub line_start: usize,
29    pub line_end: usize,
30}
31
32#[derive(Debug, Clone, Deserialize, Serialize)]
33pub(crate) struct CodewikiTruthDigest {
34    pub(crate) schema_version: u8,
35    pub(crate) generated_at: String,
36    pub(crate) project_id: String,
37    pub(crate) repo_summary: String,
38    pub(crate) stack_authority: String,
39    pub(crate) stack: Vec<CodewikiTruthStackEntry>,
40    pub(crate) key_paths: BTreeMap<String, String>,
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub(crate) superseded: Vec<CodewikiTruthSuperseded>,
43}
44
45#[derive(Debug, Clone, Deserialize, Serialize)]
46pub(crate) struct CodewikiTruthStackEntry {
47    pub(crate) service: String,
48    pub(crate) kind: String,
49    pub(crate) adapter_module: String,
50    pub(crate) pulled_in_by: Vec<String>,
51    pub(crate) summary: String,
52    pub(crate) degradation: String,
53}
54
55#[derive(Debug, Clone, Deserialize, Serialize)]
56pub(crate) struct CodewikiTruthSuperseded {
57    pub(crate) old: String,
58    pub(crate) new: String,
59}
60
61/// Builds a prompt source excerpt for `file` from its leading chunk.
62pub(crate) fn source_excerpt_for_file(
63    file: &str,
64    leading_chunks: &BTreeMap<String, LeadingChunk>,
65) -> Option<prompts::SourceExcerpt> {
66    leading_chunks
67        .get(file)
68        .map(|chunk| prompts::SourceExcerpt {
69            path: file.to_string(),
70            line_start: chunk.line_start,
71            line_end: chunk.line_end,
72            excerpt: chunk.content.clone(),
73        })
74}
75
76/// Top-k source excerpts for a set of candidate file docs, ranked by symbol
77/// count (the busiest files describe the module best) with path order as the
78/// deterministic tie-break.
79pub(crate) fn ranked_source_excerpts<'a>(
80    candidates: impl Iterator<Item = &'a FileDoc>,
81    leading_chunks: &BTreeMap<String, LeadingChunk>,
82    limit: usize,
83) -> Vec<prompts::SourceExcerpt> {
84    let mut ranked = candidates.collect::<Vec<_>>();
85    ranked.sort_by_key(|file| (std::cmp::Reverse(file.symbols.len()), file.path.clone()));
86    ranked
87        .into_iter()
88        .filter_map(|file| source_excerpt_for_file(&file.path, leading_chunks))
89        .take(limit)
90        .collect()
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct CodewikiGraphEdge {
95    pub source_component_id: String,
96    pub target_component_id: String,
97    pub kind: CodewikiGraphEdgeKind,
98}
99
100impl CodewikiGraphEdge {
101    pub fn call(
102        source_component_id: impl Into<String>,
103        target_component_id: impl Into<String>,
104    ) -> Self {
105        Self {
106            source_component_id: source_component_id.into(),
107            target_component_id: target_component_id.into(),
108            kind: CodewikiGraphEdgeKind::Call,
109        }
110    }
111
112    pub fn import(
113        source_component_id: impl Into<String>,
114        target_component_id: impl Into<String>,
115    ) -> Self {
116        Self {
117            source_component_id: source_component_id.into(),
118            target_component_id: target_component_id.into(),
119            kind: CodewikiGraphEdgeKind::Import,
120        }
121    }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum CodewikiGraphEdgeKind {
126    Call,
127    Import,
128}
129
130#[derive(Debug, Clone)]
131pub(crate) struct CodewikiGraph {
132    pub(crate) edges: Vec<CodewikiGraphEdge>,
133    pub(crate) availability: CodewikiGraphAvailability,
134}
135
136impl CodewikiGraph {
137    pub(crate) fn available(edges: Vec<CodewikiGraphEdge>) -> Self {
138        Self {
139            edges,
140            availability: CodewikiGraphAvailability::Available,
141        }
142    }
143
144    pub(crate) fn truncated(edges: Vec<CodewikiGraphEdge>) -> Self {
145        Self {
146            edges,
147            availability: CodewikiGraphAvailability::Truncated,
148        }
149    }
150
151    pub(crate) fn unavailable() -> Self {
152        Self {
153            edges: Vec::new(),
154            availability: CodewikiGraphAvailability::Unavailable,
155        }
156    }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum CodewikiGraphAvailability {
161    Available,
162    Truncated,
163    Unavailable,
164}
165
166#[derive(Debug, Clone)]
167pub(crate) struct FileDoc {
168    pub(crate) path: String,
169    pub(crate) module: String,
170    /// One-line file purpose for parent module/repo prompts and index listings.
171    pub(crate) summary: String,
172    /// The verified multi-section narrative body (`## Overview` + `## How it
173    /// fits`) rendered on the file page; the Key components table is appended by
174    /// the renderer. Empty when the doc was reused (the on-disk page is emitted
175    /// verbatim via `reused_page`).
176    pub(crate) body: String,
177    pub(crate) source_spans: Vec<SourceSpan>,
178    pub(crate) symbols: Vec<SymbolDoc>,
179    pub(crate) component_ids: Vec<String>,
180    /// True when AI generation was attempted for this doc and failed.
181    pub(crate) degraded: bool,
182    pub(crate) degraded_sources: Vec<String>,
183    pub(crate) verify_notes: Vec<VerifyNote>,
184    /// The on-disk page when the doc was reused without regeneration (#681);
185    /// emitting disk content verbatim keeps a forced rewrite lossless.
186    pub(crate) reused_page: Option<String>,
187}
188
189#[derive(Debug, Clone)]
190pub(crate) struct SymbolDoc {
191    pub(crate) symbol: Symbol,
192    pub(crate) purpose: String,
193    pub(crate) component_id: String,
194    pub(crate) component_label: String,
195    pub(crate) source_span: SourceSpan,
196    /// Deprecation reason for this symbol, detected by the codewiki source scan
197    /// (#889): `Some(reason)` when a `#[deprecated]` attribute or a `DEPRECATED`
198    /// doc-comment sits above its definition (or in its docstring). Drives the
199    /// visible "deprecated" badge in the file page's `## Key components` row.
200    /// `None` for the common, non-deprecated case. Deterministic, never
201    /// degrading.
202    pub(crate) deprecation: Option<String>,
203    /// Whether this symbol is test-gated (a `#[test]`/`#[cfg(test)]` attribute
204    /// above it, or a tests path), detected by the same deterministic source
205    /// scan that powers the dead-code page. The file page collapses test-gated
206    /// symbols into a single behavior-spec line + count instead of one
207    /// `## Reference` row each, so the readable surface is real code, not a test
208    /// roster. `false` for the common case and for the AI-off/test entry points
209    /// that pass no test index.
210    pub(crate) is_test: bool,
211}
212
213#[derive(Debug, Clone)]
214pub(crate) struct ModuleDoc {
215    pub(crate) module: String,
216    pub(crate) summary: String,
217    pub(crate) source_spans: Vec<SourceSpan>,
218    pub(crate) direct_files: Vec<FileLink>,
219    pub(crate) child_modules: Vec<ModuleLink>,
220    /// True when AI generation was attempted for this doc and failed.
221    pub(crate) degraded: bool,
222    pub(crate) degraded_sources: Vec<String>,
223    pub(crate) verify_notes: Vec<VerifyNote>,
224    /// The on-disk page when the doc was reused without regeneration (#681);
225    /// emitting disk content verbatim keeps a forced rewrite lossless.
226    pub(crate) reused_page: Option<String>,
227}
228
229#[derive(Debug, Clone)]
230pub(crate) struct ArchitectureDoc {
231    pub(crate) source_spans: Vec<SourceSpan>,
232    pub(crate) subsystems: Vec<ArchitectureSubsystem>,
233    pub(crate) narrative: Option<String>,
234    /// Pre-rendered, already-validated architectural Mermaid diagram section
235    /// seeded from the deterministic workspace [`super::SystemModel`] (#891).
236    /// `None` when no model was supplied or the model was too sparse to draw —
237    /// a missing diagram is normal and never marks the page degraded.
238    pub(crate) diagrams: Option<String>,
239    /// Pre-rendered, deterministic service matrix seeded from the same
240    /// [`super::SystemModel`] as `diagrams`: one row per service boundary with a
241    /// fixed required/degraded requirement classification and what pulls it in.
242    /// Gives an evaluator the at-a-glance "what does this need to run" picture;
243    /// the narrative is asked to narrate around it. `None` when no model was
244    /// supplied or it reached no services.
245    pub(crate) service_matrix: Option<String>,
246    pub(crate) degraded_sources: Vec<String>,
247}
248
249#[derive(Debug, Clone)]
250pub(crate) struct ArchitectureSubsystem {
251    pub(crate) module: String,
252    pub(crate) responsibility: String,
253    pub(crate) child_modules: Vec<String>,
254    pub(crate) source_spans: Vec<SourceSpan>,
255}
256
257/// One infrastructure boundary on the deterministic infra-stack page (#892):
258/// what the service is, what pulls it in, the adapter module that talks to it,
259/// and how the workspace behaves when it is unavailable. Built straight from a
260/// [`super::ServiceBoundary`] plus a curated descriptor — no LLM, never
261/// degrading.
262#[derive(Debug, Clone)]
263pub(crate) struct InfraSection {
264    pub(crate) service: String,
265    pub(crate) pulled_in_by: Vec<String>,
266    pub(crate) adapter_module: String,
267    pub(crate) summary: String,
268    pub(crate) degradation: String,
269}
270
271/// The deterministic infra-stack page (#892), one [`InfraSection`] per service
272/// boundary in the system model. `degraded_sources` is always empty: the page
273/// is derived from Cargo manifests + service boundaries and never marks itself
274/// degraded.
275#[derive(Debug, Clone)]
276pub(crate) struct InfrastructureDoc {
277    pub(crate) sections: Vec<InfraSection>,
278    pub(crate) degraded_sources: Vec<String>,
279}
280
281/// One CLI subcommand row on the deterministic feature catalog page (#888):
282/// the contract command name, its contract summary, the contract flag names,
283/// a representative handler entry symbol, and the repo-relative handler file
284/// the catalog wikilinks to as the explaining page. Built straight from the
285/// pinned CLI contract JSON plus a curated dispatch resolver — no LLM.
286#[derive(Debug, Clone)]
287pub(crate) struct FeatureEntry {
288    pub(crate) command: String,
289    pub(crate) summary: String,
290    pub(crate) key_flags: Vec<String>,
291    pub(crate) entry_symbol: String,
292    pub(crate) handler_file: String,
293}
294
295/// One binary's section on the feature catalog page: every subcommand the
296/// binary's pinned contract declares, in contract order.
297#[derive(Debug, Clone)]
298pub(crate) struct FeatureBinarySection {
299    pub(crate) binary: String,
300    pub(crate) entries: Vec<FeatureEntry>,
301}
302
303/// The deterministic feature catalog page (#888), one [`FeatureBinarySection`]
304/// per binary with a pinned contract. `degraded_sources` is always empty: the
305/// page is derived from the contract JSONs + dispatch wiring and never marks
306/// itself degraded.
307#[derive(Debug, Clone)]
308pub(crate) struct FeatureCatalogDoc {
309    pub(crate) sections: Vec<FeatureBinarySection>,
310    pub(crate) degraded_sources: Vec<String>,
311}
312
313/// Map of `symbol.id -> deprecation reason`, built once per run by the
314/// deterministic source scan (#889) and threaded into `build_file_doc` (to
315/// stamp the per-symbol badge) and the `code/deprecations.md` aggregate page.
316/// A `BTreeMap` so the aggregate page lists symbols in a stable order. Empty
317/// when nothing is deprecated; the scan never panics and never degrades.
318pub(crate) type DeprecationIndex = BTreeMap<String, String>;
319
320/// Set of `symbol.id`s that are test-gated, built by the same deterministic
321/// source scan as [`DeprecationIndex`] and threaded into `build_file_doc` to
322/// stamp `SymbolDoc::is_test`. A `BTreeSet` for stable, de-duplicated membership
323/// checks. Empty when nothing is test-gated; the scan never panics or degrades.
324pub(crate) type TestIndex = BTreeSet<String>;
325
326/// One deprecated symbol on the deterministic `code/deprecations.md` page
327/// (#889): its name, kind, defining `file:line`, the detected reason, and the
328/// file it lives in (for grouping + a `file_wikilink`).
329#[derive(Debug, Clone)]
330pub(crate) struct DeprecatedSymbol {
331    pub(crate) file: String,
332    pub(crate) name: String,
333    pub(crate) kind: String,
334    pub(crate) line: usize,
335    pub(crate) reason: String,
336}
337
338/// The deterministic deprecations aggregate page (#889), every deprecated
339/// symbol grouped by file. `degraded_sources` is always empty: the page is
340/// derived from a source scan and never marks itself degraded — even when the
341/// list is empty (it still renders a clear "no deprecations" line).
342#[derive(Debug, Clone)]
343pub(crate) struct DeprecationsDoc {
344    pub(crate) symbols: Vec<DeprecatedSymbol>,
345    pub(crate) degraded_sources: Vec<String>,
346}
347
348#[derive(Debug, Clone)]
349pub(crate) struct OnboardingDoc {
350    pub(crate) source_spans: Vec<SourceSpan>,
351    pub(crate) entry_points: Vec<OnboardingEntryPoint>,
352    pub(crate) reading_order: Vec<OnboardingStep>,
353    pub(crate) degraded_sources: Vec<String>,
354}
355
356#[derive(Debug, Clone)]
357pub(crate) struct OnboardingEntryPoint {
358    pub(crate) link: String,
359    pub(crate) description: String,
360    pub(crate) source_span: SourceSpan,
361}
362
363#[derive(Debug, Clone)]
364pub(crate) struct OnboardingStep {
365    pub(crate) module: String,
366    pub(crate) summary: String,
367    pub(crate) degree: usize,
368    pub(crate) score: f64,
369}
370
371#[derive(Debug, Clone)]
372pub(crate) struct HotspotsDoc {
373    pub(crate) source_spans: Vec<SourceSpan>,
374    pub(crate) hotspots: Vec<HotspotFinding>,
375    pub(crate) god_nodes: Vec<HotspotFinding>,
376    pub(crate) bridges: Vec<HotspotFinding>,
377    pub(crate) degraded_sources: Vec<String>,
378}
379
380#[derive(Debug, Clone)]
381pub(crate) struct HotspotFinding {
382    pub(crate) node: HotspotNode,
383    pub(crate) degree: Option<usize>,
384    pub(crate) score: Option<f64>,
385    pub(crate) frequency: Option<usize>,
386    pub(crate) weight: Option<f64>,
387}
388
389#[derive(Debug, Clone)]
390pub(crate) struct HotspotNode {
391    pub(crate) id: String,
392    pub(crate) kind: String,
393    pub(crate) label: String,
394    pub(crate) wikilink: String,
395    pub(crate) file_wikilink: Option<String>,
396    pub(crate) source_span: Option<SourceSpan>,
397}
398
399#[derive(Debug, Clone)]
400pub(crate) struct FileLink {
401    pub(crate) path: String,
402    pub(crate) summary: String,
403    pub(crate) source_spans: Vec<SourceSpan>,
404}
405
406#[derive(Debug, Clone)]
407pub(crate) struct ModuleLink {
408    pub(crate) module: String,
409    pub(crate) summary: String,
410    pub(crate) source_spans: Vec<SourceSpan>,
411}
412
413#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
414pub(crate) struct SourceSpan {
415    pub(crate) file: String,
416    pub(crate) line_start: usize,
417    pub(crate) line_end: usize,
418}
419
420const VERIFY_NOTE_REASON_LIMIT: usize = 200;
421const VERIFY_NOTE_TRUNCATION: &str = "...";
422
423#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
424pub(crate) struct VerifyNote {
425    pub(crate) id: usize,
426    pub(crate) reason: String,
427}
428
429impl VerifyNote {
430    pub(crate) fn new(id: usize, reason: impl AsRef<str>) -> Self {
431        Self {
432            id,
433            reason: normalize_verify_note_reason(reason.as_ref()),
434        }
435    }
436}
437
438fn normalize_verify_note_reason(reason: &str) -> String {
439    let reason = reason.trim();
440    if reason.chars().count() <= VERIFY_NOTE_REASON_LIMIT {
441        return reason.to_string();
442    }
443
444    let keep = VERIFY_NOTE_REASON_LIMIT.saturating_sub(VERIFY_NOTE_TRUNCATION.len());
445    let mut truncated = reason.chars().take(keep).collect::<String>();
446    truncated.push_str(VERIFY_NOTE_TRUNCATION);
447    truncated
448}
449
450#[derive(Debug, Clone, Serialize)]
451pub struct CodewikiRunSummary {
452    pub command: &'static str,
453    pub project_id: String,
454    pub project_root: String,
455    pub out_dir: String,
456    pub generated_pages: usize,
457    pub changed_paths: Vec<String>,
458    pub skipped: usize,
459    pub files: usize,
460    pub modules: usize,
461    pub symbols: usize,
462    pub ai_enabled: bool,
463    /// Pages whose AI content pass failed and fell back to the structural body
464    /// this run (#900). Empty on a fully healthy run. Surfaced here (and logged
465    /// to stderr) so curated/page degradation is visible instead of silently
466    /// cached as healthy.
467    pub degraded_pages: Vec<String>,
468}
469
470#[derive(Debug, Clone, Default, Deserialize, Serialize)]
471pub(crate) struct CodewikiMeta {
472    pub(crate) docs: BTreeMap<String, CodewikiDocMeta>,
473    pub(crate) generated_docs: Vec<String>,
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub(crate) index_snapshot: Option<CodewikiIndexSnapshot>,
476    #[serde(default)]
477    pub(crate) ai_mode: String,
478}
479
480#[derive(Debug, Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
481pub(crate) struct CodewikiDocMeta {
482    pub(crate) source_hashes: BTreeMap<String, String>,
483    /// True when the doc on disk was written from a failed generation
484    /// fallback. Source hashes cannot see generation failures, so this flag
485    /// is what lets a later successful run repair the doc (#687).
486    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
487    pub(crate) degraded: bool,
488    /// The grounded summary this doc feeds into parent prompts and pages,
489    /// recorded so an unchanged doc can be reused without an LLM call (#681).
490    /// Absent for degraded fallbacks and for docs nothing consumes.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub(crate) summary: Option<String>,
493    /// AI mode the doc on disk was generated under. Entries written before
494    /// per-doc modes existed inherit the run-level `ai_mode` at read time.
495    #[serde(default, skip_serializing_if = "String::is_empty")]
496    pub(crate) ai_mode: String,
497    /// Render-template version for deterministic markdown emitted after model
498    /// generation. Missing versions force a one-time rewrite on upgrade.
499    #[serde(default)]
500    pub(crate) render_version: u32,
501    /// Cross-file neighbor source hashes (#885, Leaf H). A source-file page
502    /// regenerates when a neighbor's content hash changes even if its own
503    /// sources did not, so a caller edit refreshes the callee's relationship
504    /// narrative. Empty for pages with no recorded cross-file neighbors.
505    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
506    pub(crate) neighbor_hashes: BTreeMap<String, String>,
507    /// Page-type invalidation digest for derived aggregate pages whose content
508    /// is a function of a model rather than a source-file set (Leaf H): the
509    /// `SystemModel` hash for architecture/infrastructure, and the rendered
510    /// contract/deprecation digest for the feature catalog and audit pages. A
511    /// function-body edit that does not change the model leaves the digest —
512    /// and the page — unchanged. `None` for source-file pages.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub(crate) invalidation_key: Option<String>,
515}
516
517/// One rendered doc plus the degradation outcome of its generation, carried
518/// to the incremental writer so `_meta/codewiki.json` can record it.
519#[derive(Debug, Clone)]
520pub(crate) struct BuiltDoc {
521    pub(crate) path: String,
522    pub(crate) content: String,
523    pub(crate) degraded: bool,
524    /// Grounded summary persisted to the doc meta so a later run can feed it
525    /// into parent prompts without regenerating this doc (#681).
526    pub(crate) summary: Option<String>,
527    /// Cross-file neighbor files whose content this page's narrative depends on
528    /// (#885, Leaf H). The sink hashes them into `neighbor_hashes` so a
529    /// neighbor change invalidates this page even when its own sources are
530    /// unchanged. Empty for pages with no cross-file dependencies.
531    pub(crate) neighbors: BTreeSet<String>,
532    /// Page-type invalidation digest for derived aggregate pages (Leaf H).
533    /// `None` for source-file pages that invalidate on source/neighbor hashes.
534    pub(crate) invalidation_key: Option<String>,
535    /// True for keyed pages whose digest covers non-source inputs but whose
536    /// provenance source hashes must still match before reuse.
537    pub(crate) invalidation_key_requires_sources: bool,
538}
539
540impl BuiltDoc {
541    pub(crate) fn healthy(path: impl Into<String>, content: String) -> Self {
542        Self {
543            path: path.into(),
544            content,
545            degraded: false,
546            summary: None,
547            neighbors: BTreeSet::new(),
548            invalidation_key: None,
549            invalidation_key_requires_sources: false,
550        }
551    }
552
553    /// A deterministic derived page (architecture, infrastructure, feature
554    /// catalog, audit) keyed on `invalidation_key` rather than a source-file
555    /// set: it is rewritten only when the digest changes (Leaf H).
556    pub(crate) fn derived(
557        path: impl Into<String>,
558        content: String,
559        invalidation_key: String,
560    ) -> Self {
561        Self {
562            path: path.into(),
563            content,
564            degraded: false,
565            summary: None,
566            neighbors: BTreeSet::new(),
567            invalidation_key: Some(invalidation_key),
568            invalidation_key_requires_sources: false,
569        }
570    }
571
572    pub(crate) fn with_source_sensitive_key(mut self) -> Self {
573        self.invalidation_key_requires_sources = true;
574        self
575    }
576
577    /// Records the cross-file neighbor files this page depends on, builder-style.
578    pub(crate) fn with_neighbors(mut self, neighbors: BTreeSet<String>) -> Self {
579        self.neighbors = neighbors;
580        self
581    }
582}
583
584#[derive(Debug, Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
585pub(crate) struct CodewikiIndexSnapshot {
586    pub(crate) files: BTreeMap<String, CodewikiFileSnapshot>,
587    pub(crate) symbols: BTreeMap<String, CodewikiSymbolSnapshot>,
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub(crate) graph_neighborhoods: Option<BTreeMap<String, String>>,
590    #[serde(default, skip_serializing_if = "Vec::is_empty")]
591    pub(crate) degraded_sources: Vec<String>,
592}
593
594#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)]
595pub(crate) struct CodewikiFileSnapshot {
596    pub(crate) content_hash: String,
597    pub(crate) symbol_count: usize,
598}
599
600#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)]
601pub(crate) struct CodewikiSymbolSnapshot {
602    pub(crate) file_path: String,
603    pub(crate) name: String,
604    pub(crate) qualified_name: String,
605    pub(crate) kind: String,
606    pub(crate) line_start: usize,
607}
608
609pub type TextGenerator<'a> = dyn FnMut(&str, &str, PromptTier) -> Option<String> + 'a;
610
611/// Grounded verification call: given a verify prompt and system prompt, returns
612/// the raw model response, or `None` when the verifier is unavailable (routed
613/// off, transport failure, or generation error). Callers treat `None` as "skip
614/// verification, proceed undegraded". The deterministic block numbering,
615/// response parsing, and stripping live in [`super::text`], so the closure is
616/// just the model call — mirroring [`TextGenerator`] but without a prompt tier.
617pub type TextVerifier<'a> = dyn FnMut(&str, &str) -> Option<String> + 'a;
618
619/// Weight tier of one codewiki generation call (#904). `Aggregate` is the
620/// top-level repo-wide synthesis — repo overview, architecture, and the curated
621/// narrative/concept layer — and is written opus-first. `Module` is mid-level
622/// per-unit synthesis (module docs and file-body narratives) and routes to
623/// sonnet. `Standard` is high-volume per-symbol prose on the default low tier.
624#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
625pub enum PromptTier {
626    #[default]
627    Standard,
628    Module,
629    Aggregate,
630}
631
632/// How deep AI prose generation reaches. Deeper tiers include shallower ones;
633/// gated tiers fall back to structural summaries.
634#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
635pub enum AiDepth {
636    /// Architecture, module, and repo prose only.
637    Sections,
638    /// Sections plus per-file summaries.
639    #[default]
640    Files,
641    /// Files plus per-symbol purposes (one LLM call per symbol).
642    Symbols,
643}
644
645impl AiDepth {
646    pub(crate) fn includes_files(self) -> bool {
647        self >= AiDepth::Files
648    }
649
650    pub(crate) fn includes_symbols(self) -> bool {
651        self >= AiDepth::Symbols
652    }
653
654    pub(crate) fn mode_label(self) -> &'static str {
655        match self {
656            AiDepth::Sections => "sections",
657            AiDepth::Files => "files",
658            AiDepth::Symbols => "symbols",
659        }
660    }
661}
662
663/// Output verbosity for AI prose, orthogonal to [`AiDepth`] (which page tiers
664/// reach the LLM) and to the audience register. Maps to a per-page output token
665/// budget; [`ProseDepth::Standard`] defers to the provider/profile default so a
666/// run without the flag is byte-identical to before this control existed.
667#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
668pub enum ProseDepth {
669    /// Tighter pages: cap output low so prose stays terse.
670    Brief,
671    /// Provider/profile default budget (unchanged behavior).
672    #[default]
673    Standard,
674    /// Richer pages: raise the output budget for longer explanations.
675    Deep,
676}
677
678impl ProseDepth {
679    /// Per-page output token budget, or `None` to defer to the provider/profile
680    /// default. `Standard` returns `None` so the default run is unchanged;
681    /// `Brief`/`Deep` pin a lower/higher ceiling.
682    pub(crate) fn max_tokens(self) -> Option<usize> {
683        match self {
684            ProseDepth::Brief => Some(640),
685            ProseDepth::Standard => None,
686            ProseDepth::Deep => Some(2_400),
687        }
688    }
689}
690
691/// Audience register for AI prose, orthogonal to depth. Every register projects
692/// the same grounded facts and only changes voice; `None` (the default) leaves
693/// the base system prompts untouched so default runs are unchanged.
694#[derive(Clone, Copy, Debug, PartialEq, Eq)]
695pub enum ProseRegister {
696    /// ELI5: plain language, defines jargon on first use, leads with the
697    /// problem the code solves.
698    Newcomer,
699    /// Maintainer: leads with why the code is shaped this way and the
700    /// non-obvious trade-offs.
701    Maintainer,
702    /// Build substrate: terse decisions and structure, minimal connective prose.
703    Agent,
704}
705
706#[derive(Clone, Debug, Default)]
707pub struct CodewikiAiOptions {
708    pub routing: Option<AiRouting>,
709    pub depth: AiDepth,
710    /// Output verbosity (per-page token budget). Default keeps prior behavior.
711    pub prose_depth: ProseDepth,
712    /// Audience register layered onto generation prompts. `None` keeps the base
713    /// voice; grounding rules hold in every register.
714    pub register: Option<ProseRegister>,
715    /// Daemon feature profile override for aggregate docs. `None` (the default)
716    /// routes aggregate/curated writing to the opus-first chain
717    /// (`writer_candidate_chain` in `text/generation.rs`); `Some(profile)` pins
718    /// that named daemon feature profile instead.
719    pub aggregate_profile: Option<String>,
720    /// Override seams for the grounded verification pass. Each `None` falls
721    /// back to the resolved `ai.text_generate.verify_*` config, then to the
722    /// generate model/key and [`super::DEFAULT_VERIFY_PROFILE`]. Kept here so
723    /// the generator set is resolved from one options value and the precedence
724    /// is unit-testable.
725    pub verify_profile: Option<String>,
726    pub verify_model: Option<String>,
727    pub verify_api_key: Option<String>,
728}
729
730impl SourceSpan {
731    pub(crate) fn from_symbol(symbol: &Symbol) -> Self {
732        Self {
733            file: symbol.file_path.clone(),
734            line_start: symbol.line_start,
735            line_end: symbol.line_end,
736        }
737    }
738
739    pub(crate) fn citation(&self) -> String {
740        if self.line_start == self.line_end {
741            format!("[{}:{}]", self.file, self.line_start)
742        } else {
743            format!("[{}:{}-{}]", self.file, self.line_start, self.line_end)
744        }
745    }
746
747    pub(crate) fn contains(&self, file: &str, line_start: usize, line_end: usize) -> bool {
748        self.file == file && self.line_start <= line_start && line_end <= self.line_end
749    }
750}