1use axum::{
6 extract::{Query, State},
7 http::HeaderMap,
8 response::{IntoResponse, Response},
9};
10use serde::{Deserialize, Serialize};
11use zeroclaw_config::api_error::{ConfigApiCode, ConfigApiError};
12use zeroclaw_runtime::rpc::types::{
13 CatalogModelProvider, CatalogModelsResult, CatalogResponse, ConfigSectionEntry,
14 ConfigSectionsResult, ConfigStatusResult, PickerItem, PickerResponse, SelectItemResponse,
15};
16
17use super::AppState;
18use super::api::require_auth;
19
20pub async fn handle_catalog(State(state): State<AppState>, headers: HeaderMap) -> Response {
24 if let Err(e) = require_auth(&state, &headers) {
25 return e.into_response();
26 }
27 let _ = state;
28
29 let model_providers: Vec<CatalogModelProvider> = zeroclaw_providers::list_model_providers()
30 .into_iter()
31 .map(|p| CatalogModelProvider {
32 name: p.name.to_string(),
33 display_name: p.display_name.to_string(),
34 local: p.local,
35 })
36 .collect();
37
38 axum::Json(CatalogResponse { model_providers }).into_response()
39}
40
41#[derive(Debug, Deserialize)]
42#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
43pub struct ModelsQuery {
44 #[serde(alias = "provider")]
47 pub model_provider: String,
48}
49
50pub async fn handle_catalog_models(
60 State(state): State<AppState>,
61 headers: HeaderMap,
62 Query(q): Query<ModelsQuery>,
63) -> Response {
64 if let Err(e) = require_auth(&state, &headers) {
65 return e.into_response();
66 }
67 let _ = state;
68 let local = zeroclaw_runtime::quickstart::model_provider_is_local(&q.model_provider);
69 let (models, pricing, live) =
70 zeroclaw_runtime::quickstart::model_catalog(&q.model_provider).await;
71 axum::Json(CatalogModelsResult {
72 model_provider: q.model_provider,
73 models,
74 pricing,
75 local,
76 live,
77 })
78 .into_response()
79}
80
81fn error_response(err: ConfigApiError) -> Response {
82 let status = axum::http::StatusCode::from_u16(err.code.http_status())
83 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
84 (status, axum::Json(err)).into_response()
85}
86
87#[must_use]
96pub fn derive_section_status(cfg: &zeroclaw_config::schema::Config) -> ConfigStatusResult {
97 let missing = quickstart_missing_requirements(cfg);
98 let ready = missing.is_empty();
99 let has_partial_state = !cfg.onboard_state.completed_sections.is_empty()
100 || cfg.providers.models.iter_entries().next().is_some()
101 || !cfg.risk_profiles.is_empty()
102 || !cfg.runtime_profiles.is_empty()
103 || !cfg.agents.is_empty();
104 let reason = if ready {
105 "has_dispatchable_agent"
106 } else if has_partial_state {
107 "incomplete_agent"
108 } else {
109 "fresh_install"
110 };
111 ConfigStatusResult {
112 needs_quickstart: !ready,
113 reason: reason.to_string(),
114 has_partial_state,
115 missing,
116 }
117}
118
119fn quickstart_missing_requirements(cfg: &zeroclaw_config::schema::Config) -> Vec<String> {
120 let mut missing = Vec::new();
121 if cfg.providers.models.iter_entries().next().is_none() {
122 missing.push("Add a model provider.".to_string());
123 }
124 if cfg.agents.is_empty() {
125 missing.push("Create an agent.".to_string());
126 return missing;
127 }
128
129 let mut agent_aliases: Vec<&String> = cfg.agents.keys().collect();
130 agent_aliases.sort();
131 let mut has_dispatchable_agent = false;
132 for alias in agent_aliases {
133 let agent_missing = quickstart_agent_missing_requirements(cfg, alias, &cfg.agents[alias]);
134 if agent_missing.is_empty() {
135 has_dispatchable_agent = true;
136 break;
137 }
138 missing.extend(agent_missing);
139 }
140 if has_dispatchable_agent {
141 missing.clear();
142 }
143 missing
144}
145
146fn quickstart_agent_missing_requirements(
147 cfg: &zeroclaw_config::schema::Config,
148 alias: &str,
149 agent: &zeroclaw_config::schema::AliasedAgentConfig,
150) -> Vec<String> {
151 let mut missing = Vec::new();
152 if !agent.enabled {
153 missing.push(format!("Enable agent `{alias}`."));
154 }
155
156 let model_ref = agent.model_provider.trim();
157 if model_ref.is_empty() {
158 missing.push(format!("Set a model provider for agent `{alias}`."));
159 } else if let Some((family, _, provider)) = cfg.resolved_model_provider_for_agent(alias) {
160 let has_model = provider
161 .model
162 .as_deref()
163 .map(str::trim)
164 .is_some_and(|m| !m.is_empty());
165 if !has_model {
166 missing.push(format!("Choose a model for model provider `{model_ref}`."));
167 } else if !model_provider_alias_usable(
168 provider,
169 zeroclaw_runtime::quickstart::model_provider_is_local(family),
170 ) {
171 missing.push(format!(
172 "Set credential/auth for model provider `{model_ref}`."
173 ));
174 }
175 } else {
176 missing.push(format!(
177 "Fix agent `{alias}` model provider `{model_ref}`; it does not resolve to a configured provider."
178 ));
179 }
180
181 let risk_ref = agent.risk_profile.trim();
182 if risk_ref.is_empty() {
183 missing.push(format!("Set a risk profile for agent `{alias}`."));
184 } else if !cfg.risk_profiles.contains_key(risk_ref) {
185 missing.push(format!(
186 "Fix agent `{alias}` risk profile `{risk_ref}`; it does not resolve to a configured profile."
187 ));
188 }
189
190 let runtime_ref = agent.runtime_profile.trim();
191 if runtime_ref.is_empty() {
192 missing.push(format!("Set a runtime profile for agent `{alias}`."));
193 } else if !cfg.runtime_profiles.contains_key(runtime_ref) {
194 missing.push(format!(
195 "Fix agent `{alias}` runtime profile `{runtime_ref}`; it does not resolve to a configured profile."
196 ));
197 }
198
199 missing
200}
201
202pub async fn handle_section_status(State(state): State<AppState>, headers: HeaderMap) -> Response {
208 if let Err(e) = require_auth(&state, &headers) {
209 return e.into_response();
210 }
211 let cfg = state.config.read().clone();
212 axum::Json(derive_section_status(&cfg)).into_response()
213}
214
215#[derive(Debug, Serialize)]
220#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
221pub struct AgentOptionsResponse {
222 pub channels: Vec<String>,
223 pub channel_types: Vec<String>,
226 pub model_providers: Vec<String>,
227 pub risk_profiles: Vec<String>,
228 pub runtime_profiles: Vec<String>,
229 pub skill_bundles: Vec<String>,
230 pub knowledge_bundles: Vec<String>,
231 pub mcp_bundles: Vec<String>,
232 pub agents: Vec<String>,
233}
234
235pub fn build_agent_options(cfg: &zeroclaw_config::schema::Config) -> AgentOptionsResponse {
245 fn dotted_aliases(cfg: &zeroclaw_config::schema::Config, prefix: &str) -> Vec<String> {
246 let mut out: Vec<String> = Vec::new();
247 for f in cfg.prop_fields() {
248 if let Some(rest) = f.name.strip_prefix(&format!("{prefix}.")) {
249 let mut parts = rest.splitn(3, '.');
250 if let (Some(ty), Some(alias), Some(_)) = (parts.next(), parts.next(), parts.next())
251 {
252 let dotted = format!("{ty}.{alias}");
253 if !out.contains(&dotted) {
254 out.push(dotted);
255 }
256 }
257 }
258 }
259 out.sort();
260 out
261 }
262
263 let channels = dotted_aliases(cfg, "channels");
264 let mut channel_types: Vec<String> = channels
265 .iter()
266 .filter_map(|d| d.split_once('.').map(|(t, _)| t.to_string()))
267 .collect();
268 channel_types.sort();
269 channel_types.dedup();
270
271 AgentOptionsResponse {
272 channels,
273 channel_types,
274 model_providers: dotted_aliases(cfg, "providers.models"),
275 risk_profiles: cfg.get_map_keys("risk_profiles").unwrap_or_default(),
276 runtime_profiles: cfg.get_map_keys("runtime_profiles").unwrap_or_default(),
277 skill_bundles: cfg.get_map_keys("skill_bundles").unwrap_or_default(),
278 knowledge_bundles: cfg.get_map_keys("knowledge_bundles").unwrap_or_default(),
279 mcp_bundles: cfg.get_map_keys("mcp_bundles").unwrap_or_default(),
280 agents: cfg.get_map_keys("agents").unwrap_or_default(),
281 }
282}
283
284pub async fn handle_agent_options(State(state): State<AppState>, headers: HeaderMap) -> Response {
288 if let Err(e) = require_auth(&state, &headers) {
289 return e.into_response();
290 }
291 let cfg = state.config.read().clone();
292 axum::Json(build_agent_options(&cfg)).into_response()
293}
294
295pub async fn handle_sections(State(state): State<AppState>, headers: HeaderMap) -> Response {
305 if let Err(e) = require_auth(&state, &headers) {
306 return e.into_response();
307 }
308 let cfg = state.config.read().clone();
309 let completed: std::collections::HashSet<String> = cfg
310 .onboard_state
311 .completed_sections
312 .iter()
313 .cloned()
314 .collect();
315
316 let mut roots: std::collections::BTreeSet<String> = cfg
319 .prop_fields()
320 .iter()
321 .filter_map(|f| f.name.split('.').next().map(str::to_string))
322 .collect();
323
324 for hidden in HIDDEN_TOP_LEVEL {
326 roots.remove(*hidden);
327 }
328
329 let all_map_paths: Vec<&'static str> = zeroclaw_config::schema::Config::map_key_sections()
336 .iter()
337 .map(|s| s.path)
338 .collect();
339 let section_has_picker_for_key = |key: &str| -> bool {
340 let key_dot = format!("{key}.");
341 all_map_paths.iter().any(|p| {
342 *p == key
343 || p.strip_prefix(&key_dot)
344 .is_some_and(|rest| !rest.contains('.'))
345 })
346 };
347
348 let map_keyed_roots: std::collections::HashSet<&'static str> = all_map_paths
353 .iter()
354 .filter_map(|p| p.split('.').next())
355 .collect();
356 for &prefix in &map_keyed_roots {
357 roots.insert(prefix.to_string());
358 }
359
360 for s in zeroclaw_config::sections::QUICKSTART_SECTIONS {
365 roots.insert(s.as_str().to_string());
366 }
367
368 let prefixes_with_children: std::collections::HashSet<String> = roots
371 .iter()
372 .filter_map(|k| k.split_once('.').map(|(parent, _)| parent.to_string()))
373 .collect();
374 roots.retain(|k| k.contains('.') || !prefixes_with_children.contains(k));
375
376 roots.retain(|k| !k.starts_with("cost.rates"));
383
384 let mut ordered: Vec<String> = roots.into_iter().collect();
390 ordered.sort_by(|a, b| {
391 match (
392 zeroclaw_config::sections::section_index_for_key(a),
393 zeroclaw_config::sections::section_index_for_key(b),
394 ) {
395 (Some(ai), Some(bi)) => ai.cmp(&bi),
396 (Some(_), None) => std::cmp::Ordering::Less,
397 (None, Some(_)) => std::cmp::Ordering::Greater,
398 (None, None) => a.cmp(b),
399 }
400 });
401
402 let sections: Vec<ConfigSectionEntry> = ordered
403 .into_iter()
404 .map(|key| {
405 let wizard = zeroclaw_config::sections::Section::from_key(&key);
411 let has_picker = match wizard {
412 Some(w) => !matches!(
413 w,
414 zeroclaw_config::sections::Section::Hardware
415 | zeroclaw_config::sections::Section::Mcp
416 | zeroclaw_config::sections::Section::Skills
417 ),
418 None => section_has_picker_for_key(&key),
419 };
420 ConfigSectionEntry {
421 completed: completed.contains(&key),
422 ready: section_ready(&cfg, &key, completed.contains(&key)),
423 label: zeroclaw_config::sections::humanize_section_key(&key),
424 help: section_help(&key).to_string(),
425 has_picker,
426 group: section_group(&key).to_string(),
427 is_quickstart: wizard.is_some(),
428 shape: wizard.map(zeroclaw_config::sections::Section::shape),
429 key,
430 }
431 })
432 .collect();
433
434 axum::Json(ConfigSectionsResult { sections }).into_response()
435}
436
437fn section_ready(cfg: &zeroclaw_config::schema::Config, key: &str, completed_marker: bool) -> bool {
438 use zeroclaw_config::sections::Section;
439 match Section::from_key(key) {
440 Some(Section::ModelProviders) => any_usable_model_provider(cfg),
441 Some(Section::RiskProfiles) => !cfg.risk_profiles.is_empty(),
442 Some(Section::RuntimeProfiles) => !cfg.runtime_profiles.is_empty(),
443 Some(Section::Storage) => cfg
444 .prop_fields()
445 .iter()
446 .any(|field| field.name.starts_with("storage.")),
447 Some(Section::Memory) => completed_marker,
448 Some(Section::Agents) => cfg.agents.iter().any(|(alias, agent)| {
449 quickstart_agent_missing_requirements(cfg, alias, agent).is_empty()
450 }),
451 _ => completed_marker,
452 }
453}
454
455const HIDDEN_TOP_LEVEL: &[&str] = &[
458 "schema_version",
459 "onboard_state",
460 "onboard-state",
461 "config_path",
462 "workspace_dir",
463 "env_overridden_paths",
464 "pre_override_snapshots",
465];
466
467fn section_group(key: &str) -> &'static str {
475 match key {
476 "providers.models" | "channels" | "memory" | "hardware" | "tunnel" | "agents"
477 | "skills" | "skill_bundles" | "risk_profiles" | "runtime_profiles" | "peer_groups" => {
478 "Foundation"
479 }
480 "agent"
482 | "cron"
483 | "heartbeat"
484 | "hooks"
485 | "pacing"
486 | "pipeline"
487 | "query_classification"
488 | "reliability"
489 | "runtime"
490 | "scheduler"
491 | "sop"
492 | "verifiable_intent" => "Agent",
493 "delegate" => "Multi-agent",
495 "browser" | "browser_delegate" | "http_request" | "image_gen" | "knowledge"
497 | "link_enricher" | "mcp" | "media_pipeline" | "multimodal" | "plugins"
498 | "project_intel" | "shell_tool" | "text_browser" | "transcription" | "tts"
499 | "web_fetch" | "web_search" => "Tools",
500 "acp" | "claude_code" | "claude_code_runner" | "codex_cli" | "composio" | "gemini_cli"
503 | "google_workspace" | "jira" | "linkedin" | "notion" | "opencode_cli" => "Integrations",
504 "gateway" | "node_transport" | "nodes" | "proxy" => "Network",
506 "identity" | "secrets" | "storage" => "Storage",
508 "backup" | "cloud_ops" | "conversational_ai" | "cost" | "data_retention"
510 | "observability" | "peripherals" | "security" | "security_ops" | "trust" => "Operations",
511 _ => "Other",
512 }
513}
514
515fn section_help(key: &str) -> &'static str {
520 zeroclaw_config::sections::section_help(key)
521}
522
523#[derive(Debug, Deserialize)]
524#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
525pub struct SectionPath {
526 pub section: String,
527}
528
529pub async fn handle_section_picker(
540 State(state): State<AppState>,
541 headers: HeaderMap,
542 axum::extract::Path(SectionPath { section }): axum::extract::Path<SectionPath>,
543) -> Response {
544 if let Err(e) = require_auth(&state, &headers) {
545 return e.into_response();
546 }
547 let cfg = state.config.read().clone();
548
549 use zeroclaw_config::sections::Section;
550 let Some(section_enum) = Section::from_key(§ion) else {
551 return error_response(
552 ConfigApiError::new(
553 ConfigApiCode::PathNotFound,
554 format!(
555 "section `{section}` has no picker; render its fields \
556 via GET /api/config/list?prefix={section}"
557 ),
558 )
559 .with_path(section.as_str()),
560 );
561 };
562 let help = section_help(section_enum.as_str()).to_string();
563 let items = match picker_items_for(section_enum, &cfg) {
564 PickerDispatch::Items(items) => items,
565 PickerDispatch::DirectForm => {
566 return error_response(
567 ConfigApiError::new(
568 ConfigApiCode::PathNotFound,
569 format!(
570 "section `{section_enum}` is a direct-form section with no picker; \
571 render fields via GET /api/config/list?prefix={section_enum}"
572 ),
573 )
574 .with_path(section_enum.as_str()),
575 );
576 }
577 };
578
579 axum::Json(PickerResponse {
580 section,
581 items,
582 help,
583 })
584 .into_response()
585}
586
587enum PickerDispatch {
596 Items(Vec<PickerItem>),
597 DirectForm,
598}
599
600fn picker_items_for(
604 section: zeroclaw_config::sections::Section,
605 cfg: &zeroclaw_config::schema::Config,
606) -> PickerDispatch {
607 use zeroclaw_config::sections::Section;
608 match section {
609 Section::ModelProviders => PickerDispatch::Items(providers_picker(cfg)),
610 Section::TtsProviders | Section::TranscriptionProviders => {
615 PickerDispatch::Items(schema_walk_picker(cfg, section.as_str()))
616 }
617 Section::Memory => PickerDispatch::Items(memory_picker(cfg)),
618 Section::Channels => PickerDispatch::Items(schema_walk_picker(cfg, "channels")),
619 Section::Tunnel => PickerDispatch::Items(schema_walk_picker_with_none(
620 cfg,
621 "tunnel",
622 "tunnel.tunnel-provider",
623 )),
624 Section::Agents => PickerDispatch::Items(agents_picker(cfg)),
625 Section::Storage => PickerDispatch::Items(storage_picker(cfg)),
628 Section::PeerGroups
632 | Section::Cron
633 | Section::McpBundles
634 | Section::KnowledgeBundles
635 | Section::SkillBundles
636 | Section::RiskProfiles
637 | Section::RuntimeProfiles => {
638 PickerDispatch::Items(one_tier_alias_map_picker(cfg, section.as_str()))
639 }
640 Section::Hardware | Section::Mcp | Section::Skills | Section::QuickstartState => {
641 PickerDispatch::DirectForm
642 }
643 }
644}
645
646fn providers_picker(cfg: &zeroclaw_config::schema::Config) -> Vec<PickerItem> {
647 zeroclaw_providers::list_model_providers()
648 .into_iter()
649 .map(|p| PickerItem {
650 key: p.name.to_string(),
651 label: p.display_name.to_string(),
652 description: if p.local {
653 Some("Local — no API key required".to_string())
654 } else {
655 None
656 },
657 badge: provider_type_badge(cfg, p.name, p.local),
658 })
659 .collect()
660}
661
662fn any_usable_model_provider(cfg: &zeroclaw_config::schema::Config) -> bool {
663 cfg.providers
664 .models
665 .iter_entries()
666 .any(|(family, _, base)| {
667 model_provider_alias_usable(
668 base,
669 zeroclaw_runtime::quickstart::model_provider_is_local(family),
670 )
671 })
672}
673
674fn provider_type_badge(
675 cfg: &zeroclaw_config::schema::Config,
676 family: &str,
677 local: bool,
678) -> Option<String> {
679 let mut has_alias = false;
680 let mut has_usable_alias = false;
681 for (ty, _, base) in cfg.providers.models.iter_entries() {
682 if ty != family {
683 continue;
684 }
685 has_alias = true;
686 if model_provider_alias_usable(base, local) {
687 has_usable_alias = true;
688 }
689 }
690 if has_usable_alias {
691 Some("configured".to_string())
692 } else if has_alias {
693 Some("needs setup".to_string())
694 } else {
695 None
696 }
697}
698
699fn model_provider_alias_usable(
700 base: &zeroclaw_config::schema::ModelProviderConfig,
701 local: bool,
702) -> bool {
703 let has_model = base
704 .model
705 .as_deref()
706 .map(str::trim)
707 .is_some_and(|model| !model.is_empty());
708 if !has_model {
709 return false;
710 }
711 base.api_key
712 .as_deref()
713 .map(str::trim)
714 .is_some_and(|key| !key.is_empty())
715 || base.requires_openai_auth
716 || local
717}
718
719fn storage_picker(cfg: &zeroclaw_config::schema::Config) -> Vec<PickerItem> {
720 let mut items = schema_walk_picker(cfg, "storage");
721 for item in &mut items {
722 item.description = storage_description(&item.key).map(str::to_string);
723 if item.badge.as_deref() == Some("configured") {
724 item.badge = Some("created".to_string());
725 }
726 }
727 items.sort_by_key(|item| storage_rank(&item.key));
728 items
729}
730
731fn storage_rank(key: &str) -> usize {
732 match key {
733 "sqlite" => 0,
734 "postgres" => 1,
735 "qdrant" => 2,
736 "markdown" => 3,
737 "lucid" => 4,
738 _ => 99,
739 }
740}
741
742fn storage_description(key: &str) -> Option<&'static str> {
743 match key {
744 "sqlite" => Some(
745 "Safe default for single-node installs: file-based, zero-config, no external service.",
746 ),
747 "postgres" => {
748 Some("Shared or multi-instance deployments that need durable server-backed storage.")
749 }
750 "qdrant" => {
751 Some("Vector database backend for semantic search when you already run Qdrant.")
752 }
753 "markdown" => {
754 Some("Human-readable files with simple local storage and no database service.")
755 }
756 "lucid" => {
757 Some("Bridge to local lucid-memory CLI while keeping SQLite-style local operation.")
758 }
759 _ => None,
760 }
761}
762
763fn memory_picker(cfg: &zeroclaw_config::schema::Config) -> Vec<PickerItem> {
764 let current = cfg.memory.backend.clone();
765 let memory_completed = cfg
766 .onboard_state
767 .completed_sections
768 .iter()
769 .any(|section| section == "memory");
770 zeroclaw_memory::selectable_memory_backends()
771 .iter()
772 .map(|b| PickerItem {
773 key: b.key.to_string(),
774 label: b.label.to_string(),
775 description: None,
776 badge: if b.key == current && memory_completed {
777 Some("active".to_string())
778 } else {
779 None
780 },
781 })
782 .collect()
783}
784
785fn schema_walk_picker(cfg: &zeroclaw_config::schema::Config, section: &str) -> Vec<PickerItem> {
791 let prefix_with_dot = format!("{section}.");
792
793 let configured: std::collections::BTreeSet<String> = cfg
795 .prop_fields()
796 .iter()
797 .filter_map(|f| f.name.strip_prefix(&prefix_with_dot))
798 .filter_map(|suffix| suffix.split_once('.').map(|(head, _)| head.to_string()))
799 .collect();
800
801 let all: std::collections::BTreeSet<String> =
804 zeroclaw_config::schema::Config::map_key_sections()
805 .into_iter()
806 .filter_map(|s| {
807 s.path
808 .strip_prefix(&prefix_with_dot)
809 .filter(|rest| !rest.contains('.'))
810 .map(String::from)
811 })
812 .collect();
813
814 all.into_iter()
815 .map(|name| {
816 let badge = if configured.contains(&name) {
820 Some("configured".to_string())
821 } else {
822 None
823 };
824 PickerItem {
825 key: name.clone(),
826 label: name.clone(),
827 description: None,
828 badge,
829 }
830 })
831 .collect()
832}
833
834fn one_tier_alias_map_picker(
844 cfg: &zeroclaw_config::schema::Config,
845 section: &str,
846) -> Vec<PickerItem> {
847 let prefix_with_dot = format!("{section}.");
848 let mut keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
849 for field in cfg.prop_fields() {
850 let Some(suffix) = field.name.strip_prefix(&prefix_with_dot) else {
851 continue;
852 };
853 let head = suffix.split_once('.').map_or(suffix, |(h, _)| h);
854 if head.is_empty() {
855 continue;
856 }
857 keys.insert(head.to_string());
858 }
859 keys.into_iter()
860 .map(|key| PickerItem {
861 key: key.clone(),
862 label: key,
863 description: None,
864 badge: Some("configured".to_string()),
865 })
866 .collect()
867}
868
869fn agents_picker(cfg: &zeroclaw_config::schema::Config) -> Vec<PickerItem> {
872 let mut items: Vec<PickerItem> = cfg
873 .agents
874 .iter()
875 .map(|(alias, agent)| PickerItem {
876 key: alias.clone(),
877 label: alias.clone(),
878 description: None,
879 badge: if agent.enabled {
880 Some("active".to_string())
881 } else {
882 Some("configured".to_string())
883 },
884 })
885 .collect();
886 items.sort_by(|a, b| a.key.cmp(&b.key));
887 items
888}
889
890fn apply_first_run_agent_defaults(cfg: &mut zeroclaw_config::schema::Config, alias: &str) {
891 let model_provider = cfg
892 .providers
893 .models
894 .iter_entries()
895 .next()
896 .map(|(ty, alias, _)| format!("{ty}.{alias}"));
897 let risk_profile = first_alias(cfg.risk_profiles.keys());
898 let runtime_profile = first_alias(cfg.runtime_profiles.keys());
899
900 let Some(agent) = cfg.agents.get_mut(alias) else {
901 return;
902 };
903 if agent.model_provider.trim().is_empty()
904 && let Some(model_provider) = model_provider
905 {
906 agent.model_provider = model_provider.into();
907 }
908 if agent.risk_profile.trim().is_empty()
909 && let Some(risk_profile) = risk_profile
910 {
911 agent.risk_profile = risk_profile;
912 }
913 if agent.runtime_profile.trim().is_empty()
914 && let Some(runtime_profile) = runtime_profile
915 {
916 agent.runtime_profile = runtime_profile;
917 }
918}
919
920fn mark_section_completed(cfg: &mut zeroclaw_config::schema::Config, section: &str) {
921 if !cfg
922 .onboard_state
923 .completed_sections
924 .iter()
925 .any(|completed| completed == section)
926 {
927 cfg.onboard_state
928 .completed_sections
929 .push(section.to_string());
930 cfg.mark_dirty("onboard_state.completed_sections");
931 }
932}
933
934fn first_alias<'a>(aliases: impl Iterator<Item = &'a String>) -> Option<String> {
935 let mut aliases: Vec<&String> = aliases.collect();
936 aliases.sort();
937 aliases.first().map(|alias| (*alias).clone())
938}
939
940fn schema_walk_picker_with_none(
944 cfg: &zeroclaw_config::schema::Config,
945 section: &str,
946 active_prop_path: &str,
947) -> Vec<PickerItem> {
948 let active = cfg.get_prop(active_prop_path).unwrap_or_default();
949 let mut items = vec![PickerItem {
950 key: "none".to_string(),
951 label: "none".to_string(),
952 description: Some("Localhost only — no public tunnel.".to_string()),
953 badge: if active == "none" || active.is_empty() {
954 Some("active".to_string())
955 } else {
956 None
957 },
958 }];
959 let mut rest = schema_walk_picker(cfg, section);
960 for item in &mut rest {
962 if item.key == active {
963 item.badge = Some("active".to_string());
964 }
965 }
966 items.extend(rest);
967 items
968}
969
970#[derive(Debug, Deserialize)]
971#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
972pub struct SectionItemPath {
973 pub section: String,
974 pub key: String,
975}
976
977#[derive(Debug, Default, Deserialize)]
993#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
994pub struct SectionSelectBody {
995 pub alias: Option<String>,
996}
997
998pub async fn handle_section_select(
999 State(state): State<AppState>,
1000 headers: HeaderMap,
1001 axum::extract::Path(SectionItemPath { section, key }): axum::extract::Path<SectionItemPath>,
1002 body: Option<axum::extract::Json<SectionSelectBody>>,
1003) -> Response {
1004 if let Err(e) = require_auth(&state, &headers) {
1005 return e.into_response();
1006 }
1007
1008 let alias = body
1009 .and_then(|b| b.0.alias)
1010 .map(|s| s.trim().to_string())
1011 .filter(|s| !s.is_empty())
1012 .unwrap_or_else(|| "default".to_string());
1013
1014 let mut working = state.config.read().clone();
1015
1016 use zeroclaw_config::sections::Section;
1017 let Some(section_enum) = Section::from_key(§ion) else {
1018 return error_response(
1019 ConfigApiError::new(
1020 ConfigApiCode::PathNotFound,
1021 format!("no picker semantics defined for section `{section}`"),
1022 )
1023 .with_path(section.as_str()),
1024 );
1025 };
1026
1027 let (fields_prefix, created) = match section_enum {
1028 Section::ModelProviders | Section::TtsProviders | Section::TranscriptionProviders => {
1029 let family = section_enum.as_str();
1035 let created = working
1036 .create_map_key(&format!("{family}.{key}"), &alias)
1037 .map_err(|msg| {
1038 error_response(
1039 ConfigApiError::new(
1040 ConfigApiCode::PathNotFound,
1041 format!("could not select {family} `{key}` alias `{alias}`: {msg}"),
1042 )
1043 .with_path(format!("{family}.{key}")),
1044 )
1045 });
1046 let created = match created {
1047 Ok(c) => c,
1048 Err(resp) => return resp,
1049 };
1050 (format!("{family}.{key}.{alias}"), created)
1053 }
1054 Section::Channels => {
1055 let created = working
1056 .create_map_key(&format!("channels.{key}"), &alias)
1057 .map_err(|msg| {
1058 error_response(
1059 ConfigApiError::new(
1060 ConfigApiCode::PathNotFound,
1061 format!("could not select channel `{key}` alias `{alias}`: {msg}"),
1062 )
1063 .with_path(format!("channels.{key}")),
1064 )
1065 });
1066 let created = match created {
1067 Ok(c) => c,
1068 Err(resp) => return resp,
1069 };
1070 if created {
1078 let enabled_path = format!("channels.{key}.{alias}.enabled");
1079 if let Err(e) = working.set_prop_persistent(&enabled_path, "true") {
1080 ::zeroclaw_log::record!(
1081 WARN,
1082 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1083 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1084 .with_attrs(
1085 ::serde_json::json!({"path": enabled_path, "error": format!("{}", e)})
1086 ),
1087 "failed to default-enable newly created channel; operator must toggle manually"
1088 );
1089 }
1090 }
1091 (format!("channels.{key}.{alias}"), created)
1092 }
1093 Section::Agents
1094 | Section::PeerGroups
1095 | Section::Cron
1096 | Section::McpBundles
1097 | Section::KnowledgeBundles
1098 | Section::SkillBundles
1099 | Section::RiskProfiles
1100 | Section::RuntimeProfiles => {
1101 let section_key = section_enum.as_str();
1107 let created = working.create_map_key(section_key, &key).map_err(|msg| {
1108 error_response(
1109 ConfigApiError::new(
1110 ConfigApiCode::PathNotFound,
1111 format!("could not select {section_key} alias `{key}`: {msg}"),
1112 )
1113 .with_path(section_key),
1114 )
1115 });
1116 let created = match created {
1117 Ok(c) => c,
1118 Err(resp) => return resp,
1119 };
1120 if created && matches!(section_enum, Section::Agents) {
1124 apply_first_run_agent_defaults(&mut working, &key);
1125 let workspace_dir = working.agent_workspace_dir(&key);
1126 if let Err(err) = tokio::fs::create_dir_all(&workspace_dir).await {
1127 return error_response(
1128 ConfigApiError::new(
1129 ConfigApiCode::ValidationFailed,
1130 format!(
1131 "created agent `{key}` but failed to scaffold workspace at {}: {err}",
1132 workspace_dir.display()
1133 ),
1134 )
1135 .with_path(section_key),
1136 );
1137 }
1138 if let Err(err) =
1139 zeroclaw_config::schema::ensure_bootstrap_files(&workspace_dir).await
1140 {
1141 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"agent": key, "workspace": workspace_dir.display().to_string(), "err": err.to_string()})), "agent workspace scaffolded but bootstrap files seed failed (continuing)");
1142 }
1143 }
1144 (format!("{section_key}.{key}"), created)
1145 }
1146 Section::Storage => {
1147 let created = working
1153 .create_map_key(&format!("storage.{key}"), &alias)
1154 .map_err(|msg| {
1155 error_response(
1156 ConfigApiError::new(
1157 ConfigApiCode::PathNotFound,
1158 format!("could not select storage `{key}` alias `{alias}`: {msg}"),
1159 )
1160 .with_path(format!("storage.{key}")),
1161 )
1162 });
1163 let created = match created {
1164 Ok(c) => c,
1165 Err(resp) => return resp,
1166 };
1167 mark_section_completed(&mut working, "storage");
1168 (format!("storage.{key}.{alias}"), created)
1169 }
1170 Section::Memory => {
1171 if let Err(e) = working.set_prop_persistent("memory.backend", &key) {
1175 return error_response(
1176 ConfigApiError::new(
1177 ConfigApiCode::ValidationFailed,
1178 format!("could not set memory.backend = `{key}`: {e}"),
1179 )
1180 .with_path("memory.backend"),
1181 );
1182 }
1183 mark_section_completed(&mut working, "memory");
1184 ("memory".to_string(), true)
1185 }
1186 Section::Tunnel => {
1187 if let Err(e) = working.set_prop_persistent("tunnel.tunnel-provider", &key) {
1188 return error_response(
1189 ConfigApiError::new(
1190 ConfigApiCode::ValidationFailed,
1191 format!("could not set tunnel.tunnel-provider = `{key}`: {e}"),
1192 )
1193 .with_path("tunnel.tunnel-provider"),
1194 );
1195 }
1196 let prefix = if key == "none" {
1197 "tunnel".to_string()
1198 } else {
1199 let p = format!("tunnel.{key}");
1200 working.init_defaults(Some(&p));
1201 p
1202 };
1203 (prefix, true)
1204 }
1205 Section::Hardware | Section::Mcp | Section::Skills | Section::QuickstartState => {
1206 return error_response(
1207 ConfigApiError::new(
1208 ConfigApiCode::PathNotFound,
1209 format!(
1210 "section `{}` is a direct-form section with no picker; \
1211 render fields via GET /api/config/list?prefix={}",
1212 section_enum, section_enum
1213 ),
1214 )
1215 .with_path(section_enum.as_str()),
1216 );
1217 }
1218 };
1219
1220 if created {
1221 working.mark_dirty(&fields_prefix);
1222 }
1223
1224 if let Err(e) = working.save_dirty().await {
1225 return error_response(ConfigApiError::new(
1226 ConfigApiCode::ReloadFailed,
1227 format!("save after select failed: {e}"),
1228 ));
1229 }
1230 *state.config.write() = working;
1231
1232 axum::Json(SelectItemResponse {
1233 fields_prefix,
1234 created,
1235 })
1236 .into_response()
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241 use super::*;
1242
1243 #[test]
1251 fn build_agent_options_returns_every_configured_alias() {
1252 let mut cfg = zeroclaw_config::schema::Config::default();
1253 cfg.create_map_key("providers.models.anthropic", "default")
1254 .unwrap();
1255 cfg.create_map_key("risk_profiles", "alpha_risk").unwrap();
1256 cfg.create_map_key("runtime_profiles", "alpha_runtime")
1257 .unwrap();
1258 cfg.create_map_key("skill_bundles", "alpha_skills").unwrap();
1259 cfg.create_map_key("knowledge_bundles", "alpha_knowledge")
1260 .unwrap();
1261 cfg.create_map_key("mcp_bundles", "alpha_mcp").unwrap();
1262 cfg.create_map_key("agents", "alpha_agent").unwrap();
1263
1264 let resp = build_agent_options(&cfg);
1265
1266 assert_eq!(resp.model_providers, vec!["anthropic.default".to_string()]);
1267 assert_eq!(resp.risk_profiles, vec!["alpha_risk".to_string()]);
1268 assert_eq!(resp.runtime_profiles, vec!["alpha_runtime".to_string()]);
1269 assert_eq!(resp.skill_bundles, vec!["alpha_skills".to_string()]);
1270 assert_eq!(resp.knowledge_bundles, vec!["alpha_knowledge".to_string()],);
1271 assert_eq!(resp.mcp_bundles, vec!["alpha_mcp".to_string()]);
1272 assert_eq!(resp.agents, vec!["alpha_agent".to_string()]);
1273 }
1274
1275 #[test]
1276 fn typed_provider_catalog_keys_create_snake_config_sections() {
1277 let mut cfg = zeroclaw_config::schema::Config::default();
1278 let cases = [
1279 ("providers.models", "atomic_chat"),
1280 ("providers.models", "gemini_cli"),
1281 ("providers.transcription", "local_whisper"),
1282 ];
1283
1284 for (family, key) in cases {
1285 let path = format!("{family}.{key}");
1286 cfg.create_map_key(&path, "default")
1287 .unwrap_or_else(|e| panic!("{key} should map to `{path}`: {e}"));
1288 }
1289
1290 assert!(
1291 cfg.providers.models.atomic_chat.contains_key("default"),
1292 "created Atomic Chat alias should land in the atomic_chat provider map",
1293 );
1294 assert!(
1295 cfg.providers.models.gemini_cli.contains_key("default"),
1296 "created Gemini CLI alias should land in the gemini_cli provider map",
1297 );
1298 assert!(
1299 cfg.providers
1300 .transcription
1301 .local_whisper
1302 .contains_key("default"),
1303 "created Local Whisper alias should land in the local_whisper provider map",
1304 );
1305 }
1306
1307 #[test]
1308 fn derive_section_status_requires_dispatchable_agent() {
1309 let mut cfg = zeroclaw_config::schema::Config::default();
1310 let resp = derive_section_status(&cfg);
1311 assert!(resp.needs_quickstart);
1312 assert_eq!(resp.reason, "fresh_install");
1313
1314 cfg.create_map_key("providers.models.anthropic", "default")
1315 .unwrap();
1316 let resp = derive_section_status(&cfg);
1317 assert!(
1318 resp.needs_quickstart,
1319 "provider configured without a bound agent must not flip needs_quickstart"
1320 );
1321 assert_eq!(resp.reason, "incomplete_agent");
1322 assert!(resp.has_partial_state);
1323
1324 cfg.create_map_key("risk_profiles", "default").unwrap();
1325 cfg.create_map_key("runtime_profiles", "default").unwrap();
1326 cfg.create_map_key("agents", "default").unwrap();
1327 let resp = derive_section_status(&cfg);
1328 assert!(
1329 resp.needs_quickstart,
1330 "agent without provider/profile bindings must still need onboarding"
1331 );
1332 assert_eq!(resp.reason, "incomplete_agent");
1333 assert!(
1334 resp.missing
1335 .iter()
1336 .any(|m| m == "Set a model provider for agent `default`.")
1337 );
1338
1339 let agent = cfg.agents.get_mut("default").unwrap();
1340 agent.model_provider = "anthropic.default".into();
1341 agent.risk_profile = "default".into();
1342 agent.runtime_profile = "default".into();
1343 let resp = derive_section_status(&cfg);
1344 assert!(
1345 resp.needs_quickstart,
1346 "provider alias without a selected model must still need onboarding"
1347 );
1348 assert!(
1349 resp.missing
1350 .iter()
1351 .any(|m| m == "Choose a model for model provider `anthropic.default`.")
1352 );
1353
1354 cfg.set_prop("providers.models.anthropic.default.model", "claude-sonnet")
1355 .unwrap();
1356 let resp = derive_section_status(&cfg);
1357 assert!(
1358 resp.needs_quickstart,
1359 "hosted provider alias without credential/auth must still need onboarding"
1360 );
1361 assert!(
1362 resp.missing
1363 .iter()
1364 .any(|m| m == "Set credential/auth for model provider `anthropic.default`.")
1365 );
1366
1367 cfg.set_prop("providers.models.anthropic.default.api_key", "sk-test")
1368 .unwrap();
1369 let resp = derive_section_status(&cfg);
1370 assert!(!resp.needs_quickstart);
1371 assert_eq!(resp.reason, "has_dispatchable_agent");
1372 assert!(!resp.has_partial_state || resp.missing.is_empty());
1373 }
1374
1375 #[test]
1376 fn derive_section_status_completed_sections_without_dispatchable_agent_stays_pending() {
1377 let mut cfg = zeroclaw_config::schema::Config::default();
1378 cfg.onboard_state
1379 .completed_sections
1380 .push("providers.models".into());
1381 let resp = derive_section_status(&cfg);
1382 assert!(
1383 resp.needs_quickstart,
1384 "completed_sections marker without a dispatchable agent must NOT flip the redirect"
1385 );
1386 assert_eq!(resp.reason, "incomplete_agent");
1387 }
1388
1389 #[test]
1390 fn apply_first_run_agent_defaults_binds_existing_provider_and_profiles() {
1391 let mut cfg = zeroclaw_config::schema::Config::default();
1392 cfg.create_map_key("providers.models.anthropic", "work")
1393 .unwrap();
1394 cfg.create_map_key("risk_profiles", "default").unwrap();
1395 cfg.create_map_key("runtime_profiles", "deep_work").unwrap();
1396 cfg.create_map_key("agents", "default").unwrap();
1397
1398 apply_first_run_agent_defaults(&mut cfg, "default");
1399
1400 let agent = cfg.agents.get("default").unwrap();
1401 assert_eq!(agent.model_provider.as_str(), "anthropic.work");
1402 assert_eq!(agent.risk_profile, "default");
1403 assert_eq!(agent.runtime_profile, "deep_work");
1404 }
1405
1406 #[test]
1407 fn memory_section_ready_tracks_onboarding_progress_not_default_backend() {
1408 let cfg = zeroclaw_config::schema::Config::default();
1409 assert!(
1410 !section_ready(&cfg, "memory", false),
1411 "fresh onboarding should not show Memory checked merely because a default backend exists"
1412 );
1413 assert!(
1414 section_ready(&cfg, "memory", true),
1415 "Memory should show checked after the user has advanced through that section"
1416 );
1417 }
1418
1419 fn empty_cfg() -> zeroclaw_config::schema::Config {
1420 zeroclaw_config::schema::Config::default()
1421 }
1422
1423 #[test]
1424 fn handle_sections_derives_every_top_level_field_from_schema() {
1425 let cfg = empty_cfg();
1429 let mut roots: std::collections::BTreeSet<String> = cfg
1430 .prop_fields()
1431 .iter()
1432 .filter_map(|f| f.name.split('.').next().map(str::to_string))
1433 .collect();
1434 for s in zeroclaw_config::schema::Config::map_key_sections() {
1438 if let Some(first) = s.path.split('.').next() {
1439 roots.insert(first.to_string());
1440 }
1441 }
1442 for hidden in HIDDEN_TOP_LEVEL {
1443 roots.remove(*hidden);
1444 }
1445 for required in ["providers", "channels", "memory", "hardware", "tunnel"] {
1447 assert!(
1448 roots.contains(required),
1449 "derived sections must include onboarding section `{required}`; got {roots:?}",
1450 );
1451 }
1452 for runtime in ["gateway", "observability", "scheduler", "security"] {
1454 assert!(
1455 roots.contains(runtime),
1456 "derived sections must include runtime section `{runtime}`; got {roots:?}",
1457 );
1458 }
1459 for hidden in HIDDEN_TOP_LEVEL {
1461 assert!(
1462 !roots.contains(*hidden),
1463 "hidden top-level `{hidden}` must not appear",
1464 );
1465 }
1466 for hidden in ["onboard_state", "onboard-state"] {
1467 assert!(
1468 !roots.contains(hidden),
1469 "onboarding bookkeeping root `{hidden}` must not appear",
1470 );
1471 }
1472 }
1473
1474 #[test]
1475 fn channels_select_initializes_subsection_so_set_prop_works() {
1476 let mut cfg = empty_cfg();
1483 assert!(cfg.channels.matrix.is_empty(), "fresh config: matrix unset");
1484
1485 cfg.create_map_key("channels.matrix", "mymatrixalias")
1486 .expect("create_map_key must succeed for channels.matrix");
1487 assert!(
1488 cfg.channels.matrix.contains_key("mymatrixalias"),
1489 "channels.matrix must have alias after create_map_key",
1490 );
1491
1492 cfg.set_prop(
1494 "channels.matrix.mymatrixalias.allowed_rooms",
1495 r#"["alice","bob"]"#,
1496 )
1497 .expect("set_prop on initialized matrix subsection must succeed");
1498 assert_eq!(
1499 cfg.channels
1500 .matrix
1501 .get("mymatrixalias")
1502 .unwrap()
1503 .allowed_rooms,
1504 vec!["alice".to_string(), "bob".to_string()],
1505 );
1506 }
1507
1508 #[test]
1509 fn providers_picker_sources_from_list_providers() {
1510 let cfg = empty_cfg();
1513 let items = providers_picker(&cfg);
1514 let names: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
1515 assert!(
1516 names.contains(&"anthropic"),
1517 "expected anthropic in picker, got: {names:?}"
1518 );
1519 assert!(names.contains(&"openai"), "expected openai in picker");
1520 assert!(
1521 names.contains(&"openrouter"),
1522 "expected openrouter in picker"
1523 );
1524
1525 let anthropic = items.iter().find(|i| i.key == "anthropic").unwrap();
1527 assert_eq!(anthropic.label, "Anthropic");
1528
1529 let local = items.iter().find(|i| i.description.is_some());
1531 assert!(
1532 local.is_some(),
1533 "at least one model_provider should be marked local"
1534 );
1535
1536 assert!(
1538 items.iter().all(|i| i.badge.is_none()),
1539 "fresh config shouldn't mark any model_provider as present"
1540 );
1541 }
1542
1543 #[test]
1544 fn providers_picker_marks_alias_readiness() {
1545 let mut cfg = empty_cfg();
1550 cfg.create_map_key("providers.models.anthropic", "default")
1551 .expect("create_map_key");
1552 let items = providers_picker(&cfg);
1553 let anthropic = items.iter().find(|i| i.key == "anthropic").unwrap();
1554 assert_eq!(
1555 anthropic.badge.as_deref(),
1556 Some("needs setup"),
1557 "anthropic should need setup after adding an empty alias"
1558 );
1559
1560 cfg.set_prop(
1561 "providers.models.anthropic.default.model",
1562 "claude-sonnet-4-5",
1563 )
1564 .expect("set model");
1565 cfg.set_prop("providers.models.anthropic.default.api_key", "sk-test")
1566 .expect("set api key");
1567 let items = providers_picker(&cfg);
1568 let anthropic = items.iter().find(|i| i.key == "anthropic").unwrap();
1569 assert_eq!(
1570 anthropic.badge.as_deref(),
1571 Some("configured"),
1572 "anthropic should be marked configured once required chat fields are present"
1573 );
1574 }
1575
1576 #[test]
1577 fn memory_picker_sources_from_selectable_backends() {
1578 let cfg = empty_cfg();
1579 let items = memory_picker(&cfg);
1580 let keys: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
1582 assert!(keys.contains(&"sqlite"));
1583 assert!(keys.contains(&"none"));
1584 let active = items.iter().find(|i| i.badge.as_deref() == Some("active"));
1586 assert!(
1587 active.is_none(),
1588 "fresh onboarding should not mark a memory backend active before the user confirms the step"
1589 );
1590 }
1591
1592 #[test]
1593 fn channels_picker_walks_schema_via_init_defaults() {
1594 let cfg = empty_cfg();
1599 let items = schema_walk_picker(&cfg, "channels");
1600 let keys: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
1601 assert!(!keys.is_empty(), "channel picker must not be empty");
1602 for expected in ["telegram", "slack", "discord"] {
1605 assert!(
1606 keys.contains(&expected),
1607 "expected `{expected}` in channels picker, got: {keys:?}"
1608 );
1609 }
1610 assert!(
1612 items.iter().all(|i| i.badge.is_none()),
1613 "fresh config shouldn't mark any channel as configured"
1614 );
1615 }
1616
1617 #[test]
1618 fn channels_picker_marks_configured_after_create_map_key() {
1619 let mut cfg = empty_cfg();
1620 cfg.create_map_key("channels.matrix", "mymatrixalias")
1621 .expect("create_map_key must succeed for channels.matrix");
1622 let items = schema_walk_picker(&cfg, "channels");
1623 let matrix = items.iter().find(|i| i.key == "matrix").unwrap();
1624 assert_eq!(
1625 matrix.badge.as_deref(),
1626 Some("configured"),
1627 "matrix should be marked configured after create_map_key"
1628 );
1629 }
1630
1631 #[test]
1632 fn tunnel_picker_includes_synthetic_none() {
1633 let cfg = empty_cfg();
1634 let items = schema_walk_picker_with_none(&cfg, "tunnel", "tunnel.tunnel-provider");
1635 assert_eq!(
1636 items[0].key, "none",
1637 "`none` must be the first entry in the tunnel picker"
1638 );
1639 assert_eq!(items[0].badge.as_deref(), Some("active"));
1641 }
1642
1643 #[test]
1649 fn one_tier_alias_map_picker_is_empty_for_unconfigured_section() {
1650 let cfg = empty_cfg();
1651 for section in [
1652 "peer_groups",
1653 "cron",
1654 "mcp_bundles",
1655 "knowledge_bundles",
1656 "skill_bundles",
1657 "risk_profiles",
1658 "runtime_profiles",
1659 ] {
1660 let items = one_tier_alias_map_picker(&cfg, section);
1661 assert!(
1662 items.is_empty(),
1663 "`{section}` picker must be empty on a fresh config, got: {:?}",
1664 items.iter().map(|i| i.key.as_str()).collect::<Vec<_>>(),
1665 );
1666 }
1667 }
1668
1669 #[test]
1674 fn one_tier_alias_map_picker_surfaces_created_aliases() {
1675 let cases: &[(&str, &str)] = &[
1676 ("peer_groups", "team_chat"),
1677 ("cron", "daily_brief"),
1678 ("mcp_bundles", "core_tools"),
1679 ("knowledge_bundles", "house_docs"),
1680 ("skill_bundles", "ops_skills"),
1681 ("risk_profiles", "tight"),
1682 ("runtime_profiles", "fast_model"),
1683 ];
1684 for (section, alias) in cases {
1685 let mut cfg = empty_cfg();
1686 cfg.create_map_key(section, alias)
1687 .unwrap_or_else(|e| panic!("create_map_key({section}, {alias}) failed: {e}"));
1688 let items = one_tier_alias_map_picker(&cfg, section);
1689 assert!(
1690 items.iter().any(|i| i.key == *alias),
1691 "`{section}` picker should surface `{alias}` after create_map_key; got: {:?}",
1692 items.iter().map(|i| i.key.as_str()).collect::<Vec<_>>(),
1693 );
1694 let entry = items.iter().find(|i| i.key == *alias).unwrap();
1695 assert_eq!(
1696 entry.badge.as_deref(),
1697 Some("configured"),
1698 "`{section}.{alias}` should be badged `configured`",
1699 );
1700 }
1701 }
1702
1703 #[test]
1712 fn picker_dispatch_covers_every_section_variant() {
1713 use zeroclaw_config::sections::Section;
1714 let cfg = empty_cfg();
1715 let all: &[Section] = &[
1719 Section::ModelProviders,
1720 Section::TtsProviders,
1721 Section::TranscriptionProviders,
1722 Section::Channels,
1723 Section::Memory,
1724 Section::Hardware,
1725 Section::Tunnel,
1726 Section::Agents,
1727 Section::PeerGroups,
1728 Section::Storage,
1729 Section::Cron,
1730 Section::Mcp,
1731 Section::McpBundles,
1732 Section::KnowledgeBundles,
1733 Section::SkillBundles,
1734 Section::RiskProfiles,
1735 Section::RuntimeProfiles,
1736 ];
1737 let direct_form = [Section::Hardware, Section::Mcp];
1738 for section in all {
1739 match picker_items_for(*section, &cfg) {
1740 PickerDispatch::Items(_items) => {
1741 assert!(
1742 !direct_form.contains(section),
1743 "{section:?} is marked DirectForm but dispatched to Items",
1744 );
1745 }
1746 PickerDispatch::DirectForm => {
1747 assert!(
1748 direct_form.contains(section),
1749 "{section:?} returned DirectForm but is not in the DirectForm set; \
1750 either give it a picker arm or add it to the DirectForm list",
1751 );
1752 }
1753 }
1754 }
1755 }
1756
1757 #[test]
1763 fn storage_picker_lists_all_kinds_and_marks_created() {
1764 let cfg = empty_cfg();
1765 let items = storage_picker(&cfg);
1766 let keys: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
1767 for expected in ["sqlite", "postgres", "qdrant", "markdown", "lucid"] {
1768 assert!(
1769 keys.contains(&expected),
1770 "storage picker must list `{expected}`, got: {keys:?}",
1771 );
1772 }
1773 assert!(
1775 items.iter().all(|i| i.badge.is_none()),
1776 "fresh config: no storage kind should be marked configured",
1777 );
1778
1779 let mut cfg2 = empty_cfg();
1781 cfg2.create_map_key("storage.sqlite", "primary")
1782 .expect("create_map_key(storage.sqlite, primary) must succeed");
1783 let items = storage_picker(&cfg2);
1784 let sqlite = items.iter().find(|i| i.key == "sqlite").unwrap();
1785 assert_eq!(
1786 sqlite.badge.as_deref(),
1787 Some("created"),
1788 "storage.sqlite should be marked created after adding an alias",
1789 );
1790 assert!(
1791 sqlite.description.is_some(),
1792 "storage picker should explain each backend tradeoff",
1793 );
1794 }
1795}