Skip to main content

llm_wiki/
config.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5
6// ── Section structs ───────────────────────────────────────────────────────────
7
8/// The `[global]` section of the global config file.
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct GlobalSection {
11    /// Name of the wiki used when no `--wiki` flag is given.
12    #[serde(default)]
13    pub default_wiki: String,
14}
15
16/// A registered wiki entry in the `[[wikis]]` array of the global config.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WikiEntry {
19    /// Short identifier used in `wiki://` URIs and the `--wiki` flag.
20    pub name: String,
21    /// Absolute path to the wiki repository root on disk.
22    pub path: String,
23    /// Optional one-line description shown in `spaces list`.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub description: Option<String>,
26    /// Optional git remote URL for the wiki repository.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub remote: Option<String>,
29}
30
31/// Default values for CLI flags that can be overridden per-wiki via `wiki.toml`.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Defaults {
34    /// Maximum number of search results returned (default: 10).
35    #[serde(default = "default_search_top_k")]
36    pub search_top_k: u32,
37    /// Whether to include BM25 excerpts in search output (default: true).
38    #[serde(default = "default_true")]
39    pub search_excerpt: bool,
40    /// Whether to include section index pages in search results (default: false).
41    #[serde(default)]
42    pub search_sections: bool,
43    /// Page display mode: `"flat"` or `"hierarchical"` (default: `"flat"`).
44    #[serde(default = "default_page_mode")]
45    pub page_mode: String,
46    /// Number of pages returned per `list` call (default: 20).
47    #[serde(default = "default_list_page_size")]
48    pub list_page_size: u32,
49    /// Default output format: `"text"` or `"json"` (default: `"text"`).
50    #[serde(default = "default_output_format")]
51    pub output_format: String,
52    /// Maximum number of tag facet values to return (default: 10).
53    #[serde(default = "default_facets_top_tags")]
54    pub facets_top_tags: u32,
55}
56
57impl Default for Defaults {
58    fn default() -> Self {
59        Self {
60            search_top_k: 10,
61            search_excerpt: true,
62            search_sections: false,
63            page_mode: "flat".into(),
64            list_page_size: 20,
65            output_format: "text".into(),
66            facets_top_tags: 10,
67        }
68    }
69}
70
71/// `[read]` section — controls how pages are read back.
72#[derive(Debug, Clone, Serialize, Deserialize, Default)]
73pub struct ReadConfig {
74    /// Strip frontmatter from `content read` output when true (default: false).
75    #[serde(default)]
76    pub no_frontmatter: bool,
77}
78
79/// `[index]` section — Tantivy index configuration.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct IndexConfig {
82    /// Automatically rebuild the index on startup when stale (default: false).
83    #[serde(default)]
84    pub auto_rebuild: bool,
85    /// Automatically recover a corrupt index by rebuilding (default: true).
86    #[serde(default = "default_true")]
87    pub auto_recovery: bool,
88    /// Tantivy index writer memory budget in megabytes (default: 50).
89    #[serde(default = "default_memory_budget_mb")]
90    pub memory_budget_mb: u32,
91    /// Tantivy tokenizer name (default: `"en_stem"`).
92    #[serde(default = "default_tokenizer")]
93    pub tokenizer: String,
94}
95
96impl Default for IndexConfig {
97    fn default() -> Self {
98        Self {
99            auto_rebuild: false,
100            auto_recovery: true,
101            memory_budget_mb: 50,
102            tokenizer: "en_stem".into(),
103        }
104    }
105}
106
107/// Graph rendering and community detection configuration.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct GraphConfig {
110    /// Default graph output format: `"mermaid"`, `"dot"`, or `"llms"` (default: `"mermaid"`).
111    #[serde(default = "default_graph_format")]
112    pub format: String,
113    /// Default hop depth for subgraph extraction (default: 3).
114    #[serde(default = "default_graph_depth")]
115    pub depth: u32,
116    /// Page types to include when no `--type` flag is given (empty = all).
117    #[serde(default)]
118    pub r#type: Vec<String>,
119    /// Default output file path for graph commands (empty = stdout).
120    #[serde(default)]
121    pub output: String,
122    /// Minimum local node count before Louvain community detection runs (default 30).
123    #[serde(default = "default_min_nodes_for_communities")]
124    pub min_nodes_for_communities: usize,
125    /// Maximum community-peer suggestions returned by `wiki_suggest` strategy 4 (default 2).
126    #[serde(default = "default_community_suggestions_limit")]
127    pub community_suggestions_limit: usize,
128    /// Enable snapshot warm-start for the graph cache (default: true).
129    /// Set false in CI or tests to avoid snapshot files.
130    #[serde(default = "default_true")]
131    pub snapshot: bool,
132    /// Number of snapshots to retain per wiki space (default: 3).
133    #[serde(default = "default_snapshot_keep")]
134    pub snapshot_keep: u32,
135    /// Snapshot format: "bincode+lz4" | "bincode" | "bincode+zstd" (default: "bincode+lz4").
136    #[serde(default = "default_snapshot_format")]
137    pub snapshot_format: String,
138    /// Enable structural topology algorithms in wiki_stats (diameter, radius, center).
139    /// Lint rules articulation-point, bridge, periphery are always available via --rules.
140    /// Default: true. Set false to skip structural computation in stats entirely.
141    #[serde(default = "default_true")]
142    pub structural_algorithms: bool,
143    /// Maximum local node count before O(n²) diameter/radius/center/periphery algorithms are skipped (default: 2000).
144    #[serde(default = "default_max_nodes_for_diameter")]
145    pub max_nodes_for_diameter: usize,
146}
147
148impl Default for GraphConfig {
149    fn default() -> Self {
150        Self {
151            format: "mermaid".into(),
152            depth: 3,
153            r#type: Vec::new(),
154            output: String::new(),
155            min_nodes_for_communities: default_min_nodes_for_communities(),
156            community_suggestions_limit: default_community_suggestions_limit(),
157            snapshot: true,
158            snapshot_keep: 3,
159            snapshot_format: "bincode+lz4".into(),
160            structural_algorithms: true,
161            max_nodes_for_diameter: default_max_nodes_for_diameter(),
162        }
163    }
164}
165
166fn default_min_nodes_for_communities() -> usize {
167    30
168}
169
170fn default_community_suggestions_limit() -> usize {
171    2
172}
173
174fn default_snapshot_keep() -> u32 {
175    3
176}
177
178fn default_snapshot_format() -> String {
179    "bincode+lz4".into()
180}
181fn default_max_nodes_for_diameter() -> usize {
182    2000
183}
184
185fn default_acp_max_sessions() -> usize {
186    20
187}
188
189/// `[serve]` section — HTTP and ACP server configuration.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct ServeConfig {
192    /// Enable the HTTP transport by default (default: false).
193    #[serde(default)]
194    pub http: bool,
195    /// TCP port for the HTTP server (default: 8080).
196    #[serde(default = "default_http_port")]
197    pub http_port: u16,
198    /// Hostnames accepted by the HTTP server (default: localhost variants).
199    #[serde(default = "default_http_allowed_hosts")]
200    pub http_allowed_hosts: Vec<String>,
201    /// Enable the ACP transport by default (default: false).
202    #[serde(default)]
203    pub acp: bool,
204    /// Maximum automatic restart attempts after a server crash (default: 10).
205    #[serde(default = "default_max_restarts")]
206    pub max_restarts: u32,
207    /// Seconds to wait between restart attempts (default: 1).
208    #[serde(default = "default_restart_backoff")]
209    pub restart_backoff: u32,
210    /// Interval in seconds between ACP heartbeat pings (default: 60).
211    #[serde(default = "default_heartbeat_secs")]
212    pub heartbeat_secs: u32,
213    /// Maximum number of concurrent ACP sessions (default: 20). Rejects NewSession when reached.
214    #[serde(default = "default_acp_max_sessions")]
215    pub acp_max_sessions: usize,
216}
217
218impl Default for ServeConfig {
219    fn default() -> Self {
220        Self {
221            http: false,
222            http_port: 8080,
223            http_allowed_hosts: default_http_allowed_hosts(),
224            acp: false,
225            max_restarts: 10,
226            restart_backoff: 1,
227            heartbeat_secs: 60,
228            acp_max_sessions: default_acp_max_sessions(),
229        }
230    }
231}
232
233/// `[validation]` section — frontmatter validation strictness.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct ValidationConfig {
236    /// How strictly unknown types are treated: `"loose"` (warn) or `"strict"` (error) (default: `"loose"`).
237    #[serde(default = "default_type_strictness")]
238    pub type_strictness: String,
239}
240
241impl Default for ValidationConfig {
242    fn default() -> Self {
243        Self {
244            type_strictness: "loose".into(),
245        }
246    }
247}
248
249/// `[logging]` section — structured log file configuration.
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct LoggingConfig {
252    /// Directory where log files are written (default: `~/.llm-wiki/logs`).
253    #[serde(default = "default_log_path")]
254    pub log_path: String,
255    /// Log rotation policy: `"daily"` or `"never"` (default: `"daily"`).
256    #[serde(default = "default_log_rotation")]
257    pub log_rotation: String,
258    /// Maximum number of log files to retain before pruning (default: 7).
259    #[serde(default = "default_log_max_files")]
260    pub log_max_files: u32,
261    /// Log line format: `"text"` or `"json"` (default: `"text"`).
262    #[serde(default = "default_log_format")]
263    pub log_format: String,
264}
265
266impl Default for LoggingConfig {
267    fn default() -> Self {
268        Self {
269            log_path: default_log_path(),
270            log_rotation: "daily".into(),
271            log_max_files: 7,
272            log_format: "text".into(),
273        }
274    }
275}
276
277/// `[ingest]` section — controls ingest commit behaviour.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct IngestConfig {
280    /// Automatically commit ingested files to git after validation (default: true).
281    #[serde(default = "default_true")]
282    pub auto_commit: bool,
283}
284
285impl Default for IngestConfig {
286    fn default() -> Self {
287        Self { auto_commit: true }
288    }
289}
290
291/// `[history]` section — git log / history command defaults.
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct HistoryConfig {
294    /// Enable `--follow` rename tracking in git log (default: true).
295    #[serde(default = "default_true")]
296    pub follow: bool,
297    /// Default maximum number of history entries to return (default: 10).
298    #[serde(default = "default_history_limit")]
299    pub default_limit: u32,
300}
301
302impl Default for HistoryConfig {
303    fn default() -> Self {
304        Self {
305            follow: true,
306            default_limit: 10,
307        }
308    }
309}
310
311/// `[watch]` section — filesystem watcher configuration.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct WatchConfig {
314    /// Debounce delay in milliseconds before triggering ingest after a file change (default: 500).
315    #[serde(default = "default_debounce_ms")]
316    pub debounce_ms: u32,
317}
318
319impl Default for WatchConfig {
320    fn default() -> Self {
321        Self { debounce_ms: 500 }
322    }
323}
324
325/// `[suggest]` section — related-page suggestion defaults.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct SuggestConfig {
328    /// Default maximum number of suggestions to return (default: 5).
329    #[serde(default = "default_suggest_limit")]
330    pub default_limit: u32,
331    /// Minimum relevance score for a suggestion to be included (default: 0.1).
332    #[serde(default = "default_suggest_min_score")]
333    pub min_score: f32,
334}
335
336impl Default for SuggestConfig {
337    fn default() -> Self {
338        Self {
339            default_limit: 5,
340            min_score: 0.1,
341        }
342    }
343}
344
345/// `[search]` section — BM25 score multipliers by page status.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct SearchConfig {
348    /// Map of status value → score multiplier applied to BM25 results.
349    #[serde(default = "default_search_status")]
350    pub status: std::collections::HashMap<String, f32>,
351}
352
353fn default_search_status() -> std::collections::HashMap<String, f32> {
354    [
355        ("active".into(), 1.0_f32),
356        ("draft".into(), 0.8),
357        ("archived".into(), 0.3),
358        ("unknown".into(), 0.9),
359    ]
360    .into_iter()
361    .collect()
362}
363
364impl Default for SearchConfig {
365    fn default() -> Self {
366        Self {
367            status: default_search_status(),
368        }
369    }
370}
371
372/// Configuration for the `stale` lint rule.
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct LintConfig {
375    /// Pages not updated within this many days are candidates for the `stale` rule (default 90).
376    #[serde(default = "default_stale_days")]
377    pub stale_days: u32,
378    /// `stale` only fires when `confidence` is also below this threshold (default 0.4).
379    #[serde(default = "default_stale_confidence_threshold")]
380    pub stale_confidence_threshold: f32,
381}
382
383impl Default for LintConfig {
384    fn default() -> Self {
385        Self {
386            stale_days: default_stale_days(),
387            stale_confidence_threshold: default_stale_confidence_threshold(),
388        }
389    }
390}
391
392/// A user-defined redaction rule added to the built-in patterns.
393#[derive(Debug, Clone, Serialize, Deserialize, Default)]
394pub struct CustomPattern {
395    /// Unique name used in redaction reports.
396    pub name: String,
397    /// Regex pattern to match sensitive text.
398    pub pattern: String,
399    /// Replacement string substituted for matched text (e.g. `"[REDACTED]"`).
400    pub replacement: String,
401}
402
403/// `[redact]` section — sensitive-data redaction configuration.
404#[derive(Debug, Clone, Serialize, Deserialize, Default)]
405pub struct RedactConfig {
406    /// Built-in pattern names to disable (e.g. `["aws-key"]`).
407    #[serde(default)]
408    pub disable: Vec<String>,
409    /// Additional user-defined redaction patterns.
410    #[serde(default)]
411    pub patterns: Vec<CustomPattern>,
412}
413
414// ── Composite configs ─────────────────────────────────────────────────────────
415
416/// Root structure for `~/.llm-wiki/config.toml` — the global configuration file.
417#[derive(Debug, Clone, Serialize, Deserialize, Default)]
418pub struct GlobalConfig {
419    /// `[global]` section.
420    #[serde(default)]
421    pub global: GlobalSection,
422    /// `[[wikis]]` array — registered wiki spaces.
423    #[serde(default)]
424    pub wikis: Vec<WikiEntry>,
425    /// `[defaults]` section — CLI flag defaults.
426    #[serde(default)]
427    pub defaults: Defaults,
428    /// `[read]` section.
429    #[serde(default)]
430    pub read: ReadConfig,
431    /// `[index]` section.
432    #[serde(default)]
433    pub index: IndexConfig,
434    /// `[graph]` section.
435    #[serde(default)]
436    pub graph: GraphConfig,
437    /// `[serve]` section.
438    #[serde(default)]
439    pub serve: ServeConfig,
440    /// `[validation]` section.
441    #[serde(default)]
442    pub validation: ValidationConfig,
443    /// `[ingest]` section.
444    #[serde(default)]
445    pub ingest: IngestConfig,
446    /// `[history]` section.
447    #[serde(default)]
448    pub history: HistoryConfig,
449    /// `[suggest]` section.
450    #[serde(default)]
451    pub suggest: SuggestConfig,
452    /// `[search]` section.
453    #[serde(default)]
454    pub search: SearchConfig,
455    /// `[lint]` section.
456    #[serde(default)]
457    pub lint: LintConfig,
458    /// `[logging]` section.
459    #[serde(default)]
460    pub logging: LoggingConfig,
461    /// `[watch]` section.
462    #[serde(default)]
463    pub watch: WatchConfig,
464    /// `[redact]` section.
465    #[serde(default)]
466    pub redact: RedactConfig,
467}
468
469/// A type entry in `[types.<name>]` of `wiki.toml`.
470#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct TypeEntry {
472    /// Relative path to the JSON Schema file for this type.
473    pub schema: String,
474    /// Human-readable description of the type.
475    pub description: String,
476}
477
478/// Per-wiki configuration loaded from `<wiki-root>/wiki.toml`.
479///
480/// Fields present here override the corresponding `GlobalConfig` sections.
481#[derive(Debug, Clone, Serialize, Deserialize, Default)]
482pub struct WikiConfig {
483    /// Wiki display name (informational; used in export headers).
484    #[serde(default)]
485    pub name: String,
486    /// One-line description of the wiki.
487    #[serde(default)]
488    pub description: String,
489    /// `[types.<name>]` custom type registrations for this wiki.
490    #[serde(default)]
491    pub types: std::collections::HashMap<String, TypeEntry>,
492    /// Per-wiki override for `[defaults]`.
493    #[serde(default)]
494    pub defaults: Option<Defaults>,
495    /// Per-wiki override for `[read]`.
496    #[serde(default)]
497    pub read: Option<ReadConfig>,
498    /// Per-wiki override for `[validation]`.
499    #[serde(default)]
500    pub validation: Option<ValidationConfig>,
501    /// Per-wiki override for `[ingest]`.
502    #[serde(default)]
503    pub ingest: Option<IngestConfig>,
504    /// Per-wiki override for `[graph]`.
505    #[serde(default)]
506    pub graph: Option<GraphConfig>,
507    /// Per-wiki override for `[history]`.
508    #[serde(default)]
509    pub history: Option<HistoryConfig>,
510    /// Per-wiki override for `[suggest]`.
511    #[serde(default)]
512    pub suggest: Option<SuggestConfig>,
513    /// Per-wiki override for `[search]`.
514    #[serde(default)]
515    pub search: Option<SearchConfig>,
516    /// Per-wiki override for `[lint]`.
517    #[serde(default)]
518    pub lint: Option<LintConfig>,
519    /// Per-wiki override for `[redact]`.
520    #[serde(default)]
521    pub redact: Option<RedactConfig>,
522    /// Content directory relative to repo root. Default: `"wiki"`.
523    #[serde(default = "default_wiki_root")]
524    pub wiki_root: String,
525}
526
527/// Fully merged config for a specific wiki — global settings overlaid with per-wiki overrides.
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct ResolvedConfig {
530    /// Resolved defaults section.
531    pub defaults: Defaults,
532    /// Resolved read section.
533    pub read: ReadConfig,
534    /// Resolved index section (always from global).
535    pub index: IndexConfig,
536    /// Resolved graph section.
537    pub graph: GraphConfig,
538    /// Resolved serve section (always from global).
539    pub serve: ServeConfig,
540    /// Resolved ingest section.
541    pub ingest: IngestConfig,
542    /// Resolved validation section.
543    pub validation: ValidationConfig,
544    /// Resolved history section.
545    pub history: HistoryConfig,
546    /// Resolved suggest section.
547    pub suggest: SuggestConfig,
548    /// Resolved search section (merged: per-wiki entries override global entries).
549    pub search: SearchConfig,
550    /// Resolved lint section.
551    pub lint: LintConfig,
552    /// Resolved redact section.
553    pub redact: RedactConfig,
554}
555
556// ── Default value helpers ─────────────────────────────────────────────────────
557
558fn default_search_top_k() -> u32 {
559    10
560}
561fn default_true() -> bool {
562    true
563}
564fn default_page_mode() -> String {
565    "flat".into()
566}
567fn default_list_page_size() -> u32 {
568    20
569}
570fn default_output_format() -> String {
571    "text".into()
572}
573fn default_facets_top_tags() -> u32 {
574    10
575}
576fn default_memory_budget_mb() -> u32 {
577    50
578}
579fn default_tokenizer() -> String {
580    "en_stem".into()
581}
582fn default_graph_format() -> String {
583    "mermaid".into()
584}
585fn default_graph_depth() -> u32 {
586    3
587}
588fn default_http_port() -> u16 {
589    8080
590}
591fn default_http_allowed_hosts() -> Vec<String> {
592    vec!["localhost".into(), "127.0.0.1".into(), "::1".into()]
593}
594fn default_max_restarts() -> u32 {
595    10
596}
597fn default_restart_backoff() -> u32 {
598    1
599}
600fn default_heartbeat_secs() -> u32 {
601    60
602}
603fn default_type_strictness() -> String {
604    "loose".into()
605}
606fn default_log_path() -> String {
607    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
608    std::path::PathBuf::from(home)
609        .join(".llm-wiki")
610        .join("logs")
611        .to_string_lossy()
612        .into()
613}
614fn default_log_rotation() -> String {
615    "daily".into()
616}
617fn default_log_max_files() -> u32 {
618    7
619}
620fn default_log_format() -> String {
621    "text".into()
622}
623fn default_history_limit() -> u32 {
624    10
625}
626fn default_debounce_ms() -> u32 {
627    500
628}
629fn default_suggest_limit() -> u32 {
630    5
631}
632fn default_suggest_min_score() -> f32 {
633    0.1
634}
635fn default_stale_days() -> u32 {
636    90
637}
638fn default_stale_confidence_threshold() -> f32 {
639    0.4
640}
641fn default_wiki_root() -> String {
642    "wiki".to_string()
643}
644// ── Functions ─────────────────────────────────────────────────────────────────
645
646/// Merge global and per-wiki config into a `ResolvedConfig` for a specific wiki.
647pub fn resolve(global: &GlobalConfig, per_wiki: &WikiConfig) -> ResolvedConfig {
648    ResolvedConfig {
649        defaults: per_wiki
650            .defaults
651            .clone()
652            .unwrap_or_else(|| global.defaults.clone()),
653        read: per_wiki.read.clone().unwrap_or_else(|| global.read.clone()),
654        index: global.index.clone(),
655        graph: per_wiki
656            .graph
657            .clone()
658            .unwrap_or_else(|| global.graph.clone()),
659        serve: global.serve.clone(),
660        ingest: per_wiki
661            .ingest
662            .clone()
663            .unwrap_or_else(|| global.ingest.clone()),
664        validation: per_wiki
665            .validation
666            .clone()
667            .unwrap_or_else(|| global.validation.clone()),
668        history: per_wiki
669            .history
670            .clone()
671            .unwrap_or_else(|| global.history.clone()),
672        suggest: per_wiki
673            .suggest
674            .clone()
675            .unwrap_or_else(|| global.suggest.clone()),
676        search: {
677            let mut merged = global.search.status.clone();
678            if let Some(wiki_search) = &per_wiki.search {
679                for (k, v) in &wiki_search.status {
680                    merged.insert(k.clone(), *v);
681                }
682            }
683            SearchConfig { status: merged }
684        },
685        lint: per_wiki.lint.clone().unwrap_or_else(|| global.lint.clone()),
686        redact: per_wiki
687            .redact
688            .clone()
689            .unwrap_or_else(|| global.redact.clone()),
690    }
691}
692
693/// Load the global config from a TOML file. Returns default config if the file is absent.
694pub fn load_global(path: &Path) -> Result<GlobalConfig> {
695    if !path.exists() {
696        return Ok(GlobalConfig::default());
697    }
698    let content = std::fs::read_to_string(path)
699        .with_context(|| format!("failed to read {}", path.display()))?;
700    let config: GlobalConfig =
701        toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))?;
702    Ok(config)
703}
704
705/// Load per-wiki config from `<wiki_root>/wiki.toml`. Returns default config if absent.
706pub fn load_wiki(wiki_root: &Path) -> Result<WikiConfig> {
707    let path = wiki_root.join("wiki.toml");
708    if !path.exists() {
709        return Ok(WikiConfig::default());
710    }
711    let content = std::fs::read_to_string(&path)
712        .with_context(|| format!("failed to read {}", path.display()))?;
713    let config: WikiConfig =
714        toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))?;
715    Ok(config)
716}
717
718/// Serialize and write the global config to `path`, creating parent dirs if needed.
719pub fn save_global(config: &GlobalConfig, path: &Path) -> Result<()> {
720    if let Some(parent) = path.parent() {
721        std::fs::create_dir_all(parent)?;
722    }
723    let content = toml::to_string_pretty(config)?;
724    std::fs::write(path, content)?;
725    Ok(())
726}
727
728/// Serialize and write the per-wiki config to `<wiki_root>/wiki.toml`.
729pub fn save_wiki(config: &WikiConfig, wiki_root: &Path) -> Result<()> {
730    let path = wiki_root.join("wiki.toml");
731    let content = toml::to_string_pretty(config)?;
732    std::fs::write(path, content)?;
733    Ok(())
734}
735
736/// Set a dot-notation config key on a `GlobalConfig` in place. Errors on unknown keys.
737pub fn set_global_config_value(global: &mut GlobalConfig, key: &str, value: &str) -> Result<()> {
738    match key {
739        "global.default_wiki" => global.global.default_wiki = value.into(),
740        "defaults.search_top_k" => global.defaults.search_top_k = value.parse()?,
741        "defaults.search_excerpt" => global.defaults.search_excerpt = value.parse()?,
742        "defaults.search_sections" => global.defaults.search_sections = value.parse()?,
743        "defaults.page_mode" => global.defaults.page_mode = value.into(),
744        "defaults.list_page_size" => global.defaults.list_page_size = value.parse()?,
745        "defaults.output_format" => global.defaults.output_format = value.into(),
746        "defaults.facets_top_tags" => global.defaults.facets_top_tags = value.parse()?,
747        "read.no_frontmatter" => global.read.no_frontmatter = value.parse()?,
748        "index.auto_rebuild" => global.index.auto_rebuild = value.parse()?,
749        "index.auto_recovery" => global.index.auto_recovery = value.parse()?,
750        "index.memory_budget_mb" => global.index.memory_budget_mb = value.parse()?,
751        "index.tokenizer" => global.index.tokenizer = value.into(),
752        "graph.format" => global.graph.format = value.into(),
753        "graph.depth" => global.graph.depth = value.parse()?,
754        "graph.output" => global.graph.output = value.into(),
755        "graph.snapshot" => global.graph.snapshot = value.parse()?,
756        "graph.snapshot_keep" => global.graph.snapshot_keep = value.parse()?,
757        "graph.snapshot_format" => global.graph.snapshot_format = value.into(),
758        "graph.structural_algorithms" => global.graph.structural_algorithms = value.parse()?,
759        "graph.max_nodes_for_diameter" => global.graph.max_nodes_for_diameter = value.parse()?,
760        "serve.http" => global.serve.http = value.parse()?,
761        "serve.http_port" => global.serve.http_port = value.parse()?,
762        "serve.http_allowed_hosts" => {
763            global.serve.http_allowed_hosts =
764                value.split(',').map(|s| s.trim().to_string()).collect();
765        }
766        "serve.acp" => global.serve.acp = value.parse()?,
767        "serve.max_restarts" => global.serve.max_restarts = value.parse()?,
768        "serve.restart_backoff" => global.serve.restart_backoff = value.parse()?,
769        "serve.heartbeat_secs" => global.serve.heartbeat_secs = value.parse()?,
770        "serve.acp_max_sessions" => global.serve.acp_max_sessions = value.parse()?,
771        "ingest.auto_commit" => global.ingest.auto_commit = value.parse()?,
772        "history.follow" => global.history.follow = value.parse()?,
773        "history.default_limit" => global.history.default_limit = value.parse()?,
774        "suggest.default_limit" => global.suggest.default_limit = value.parse()?,
775        "suggest.min_score" => global.suggest.min_score = value.parse()?,
776        "validation.type_strictness" => global.validation.type_strictness = value.into(),
777        "logging.log_path" => global.logging.log_path = value.into(),
778        "logging.log_rotation" => global.logging.log_rotation = value.into(),
779        "logging.log_max_files" => global.logging.log_max_files = value.parse()?,
780        "logging.log_format" => global.logging.log_format = value.into(),
781        "watch.debounce_ms" => global.watch.debounce_ms = value.parse()?,
782        _ => anyhow::bail!("unknown key: {key}"),
783    }
784    Ok(())
785}
786
787/// Read a dot-notation config key from `ResolvedConfig`/`GlobalConfig`. Returns `"unknown key"` for unrecognized keys.
788pub fn get_config_value(resolved: &ResolvedConfig, global: &GlobalConfig, key: &str) -> String {
789    match key {
790        "global.default_wiki" => global.global.default_wiki.clone(),
791        "defaults.search_top_k" => resolved.defaults.search_top_k.to_string(),
792        "defaults.search_excerpt" => resolved.defaults.search_excerpt.to_string(),
793        "defaults.search_sections" => resolved.defaults.search_sections.to_string(),
794        "defaults.page_mode" => resolved.defaults.page_mode.clone(),
795        "defaults.list_page_size" => resolved.defaults.list_page_size.to_string(),
796        "defaults.output_format" => resolved.defaults.output_format.clone(),
797        "defaults.facets_top_tags" => resolved.defaults.facets_top_tags.to_string(),
798        "read.no_frontmatter" => resolved.read.no_frontmatter.to_string(),
799        "index.auto_rebuild" => resolved.index.auto_rebuild.to_string(),
800        "index.auto_recovery" => global.index.auto_recovery.to_string(),
801        "index.memory_budget_mb" => global.index.memory_budget_mb.to_string(),
802        "index.tokenizer" => global.index.tokenizer.clone(),
803        "graph.format" => resolved.graph.format.clone(),
804        "graph.depth" => resolved.graph.depth.to_string(),
805        "graph.output" => resolved.graph.output.clone(),
806        "graph.snapshot" => resolved.graph.snapshot.to_string(),
807        "graph.snapshot_keep" => resolved.graph.snapshot_keep.to_string(),
808        "graph.snapshot_format" => resolved.graph.snapshot_format.clone(),
809        "graph.structural_algorithms" => resolved.graph.structural_algorithms.to_string(),
810        "graph.max_nodes_for_diameter" => resolved.graph.max_nodes_for_diameter.to_string(),
811        "serve.http" => resolved.serve.http.to_string(),
812        "serve.http_port" => resolved.serve.http_port.to_string(),
813        "serve.http_allowed_hosts" => resolved.serve.http_allowed_hosts.join(","),
814        "serve.acp" => resolved.serve.acp.to_string(),
815        "serve.max_restarts" => global.serve.max_restarts.to_string(),
816        "serve.restart_backoff" => global.serve.restart_backoff.to_string(),
817        "serve.heartbeat_secs" => global.serve.heartbeat_secs.to_string(),
818        "serve.acp_max_sessions" => global.serve.acp_max_sessions.to_string(),
819        "validation.type_strictness" => resolved.validation.type_strictness.clone(),
820        "logging.log_path" => global.logging.log_path.clone(),
821        "logging.log_rotation" => global.logging.log_rotation.clone(),
822        "logging.log_max_files" => global.logging.log_max_files.to_string(),
823        "logging.log_format" => global.logging.log_format.clone(),
824        "watch.debounce_ms" => global.watch.debounce_ms.to_string(),
825        "ingest.auto_commit" => resolved.ingest.auto_commit.to_string(),
826        "history.follow" => resolved.history.follow.to_string(),
827        "history.default_limit" => resolved.history.default_limit.to_string(),
828        "suggest.default_limit" => resolved.suggest.default_limit.to_string(),
829        "suggest.min_score" => resolved.suggest.min_score.to_string(),
830        _ => format!("unknown key: {key}"),
831    }
832}
833
834/// Set a dot-notation config key on a `WikiConfig` in place. Errors on global-only or unknown keys.
835pub fn set_wiki_config_value(wiki_cfg: &mut WikiConfig, key: &str, value: &str) -> Result<()> {
836    match key {
837        "defaults.search_top_k" => {
838            wiki_cfg
839                .defaults
840                .get_or_insert_with(Defaults::default)
841                .search_top_k = value.parse()?;
842        }
843        "defaults.search_excerpt" => {
844            wiki_cfg
845                .defaults
846                .get_or_insert_with(Defaults::default)
847                .search_excerpt = value.parse()?;
848        }
849        "defaults.search_sections" => {
850            wiki_cfg
851                .defaults
852                .get_or_insert_with(Defaults::default)
853                .search_sections = value.parse()?;
854        }
855        "defaults.page_mode" => {
856            wiki_cfg
857                .defaults
858                .get_or_insert_with(Defaults::default)
859                .page_mode = value.into();
860        }
861        "defaults.list_page_size" => {
862            wiki_cfg
863                .defaults
864                .get_or_insert_with(Defaults::default)
865                .list_page_size = value.parse()?;
866        }
867        "defaults.output_format" => {
868            wiki_cfg
869                .defaults
870                .get_or_insert_with(Defaults::default)
871                .output_format = value.into();
872        }
873        "defaults.facets_top_tags" => {
874            wiki_cfg
875                .defaults
876                .get_or_insert_with(Defaults::default)
877                .facets_top_tags = value.parse()?;
878        }
879        "read.no_frontmatter" => {
880            wiki_cfg
881                .read
882                .get_or_insert_with(ReadConfig::default)
883                .no_frontmatter = value.parse()?;
884        }
885        "validation.type_strictness" => {
886            wiki_cfg
887                .validation
888                .get_or_insert_with(ValidationConfig::default)
889                .type_strictness = value.into();
890        }
891        "ingest.auto_commit" => {
892            wiki_cfg
893                .ingest
894                .get_or_insert_with(IngestConfig::default)
895                .auto_commit = value.parse()?;
896        }
897        "history.follow" => {
898            wiki_cfg
899                .history
900                .get_or_insert_with(HistoryConfig::default)
901                .follow = value.parse()?;
902        }
903        "history.default_limit" => {
904            wiki_cfg
905                .history
906                .get_or_insert_with(HistoryConfig::default)
907                .default_limit = value.parse()?;
908        }
909        "suggest.default_limit" => {
910            wiki_cfg
911                .suggest
912                .get_or_insert_with(SuggestConfig::default)
913                .default_limit = value.parse()?;
914        }
915        "suggest.min_score" => {
916            wiki_cfg
917                .suggest
918                .get_or_insert_with(SuggestConfig::default)
919                .min_score = value.parse()?;
920        }
921        "graph.format" => {
922            wiki_cfg
923                .graph
924                .get_or_insert_with(GraphConfig::default)
925                .format = value.into();
926        }
927        "graph.depth" => {
928            wiki_cfg
929                .graph
930                .get_or_insert_with(GraphConfig::default)
931                .depth = value.parse()?;
932        }
933        "graph.output" => {
934            wiki_cfg
935                .graph
936                .get_or_insert_with(GraphConfig::default)
937                .output = value.into();
938        }
939        "graph.snapshot" => {
940            wiki_cfg
941                .graph
942                .get_or_insert_with(GraphConfig::default)
943                .snapshot = value.parse()?;
944        }
945        "graph.snapshot_keep" => {
946            wiki_cfg
947                .graph
948                .get_or_insert_with(GraphConfig::default)
949                .snapshot_keep = value.parse()?;
950        }
951        "graph.snapshot_format" => {
952            wiki_cfg
953                .graph
954                .get_or_insert_with(GraphConfig::default)
955                .snapshot_format = value.into();
956        }
957        "graph.structural_algorithms" => {
958            wiki_cfg
959                .graph
960                .get_or_insert_with(GraphConfig::default)
961                .structural_algorithms = value.parse()?;
962        }
963        "graph.max_nodes_for_diameter" => {
964            wiki_cfg
965                .graph
966                .get_or_insert_with(GraphConfig::default)
967                .max_nodes_for_diameter = value.parse()?;
968        }
969        "global.default_wiki"
970        | "index.auto_rebuild"
971        | "index.auto_recovery"
972        | "index.memory_budget_mb"
973        | "index.tokenizer"
974        | "serve.http"
975        | "serve.http_port"
976        | "serve.http_allowed_hosts"
977        | "serve.acp"
978        | "serve.max_restarts"
979        | "serve.restart_backoff"
980        | "serve.heartbeat_secs"
981        | "serve.acp_max_sessions"
982        | "logging.log_path"
983        | "logging.log_rotation"
984        | "logging.log_max_files"
985        | "logging.log_format" => {
986            anyhow::bail!("{key} is a global-only key \u{2014} use --global");
987        }
988        "watch.debounce_ms" => {
989            anyhow::bail!("{key} is a global-only key \u{2014} use --global");
990        }
991        _ => anyhow::bail!("unknown key: {key}"),
992    }
993    Ok(())
994}