1use serde::{Deserialize, Serialize};
12
13use zeroclaw_config::helpers::kebab_to_snake;
14use zeroclaw_config::presets::{
15 AgentIdentity, BuilderSubmission, ChannelQuickStart, MemoryChoice, ModelProviderChoice,
16 SelectorChoice, risk_preset, runtime_preset,
17};
18use zeroclaw_config::schema::Config;
19
20#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "snake_case")]
25pub enum Surface {
26 Web,
27 Tui,
28 Cli,
29 Test,
30}
31
32impl Surface {
33 pub fn as_str(self) -> &'static str {
34 match self {
35 Surface::Web => "web",
36 Surface::Tui => "tui",
37 Surface::Cli => "cli",
38 Surface::Test => "test",
39 }
40 }
41}
42
43struct RunCtx {
48 run_id: String,
49 surface: Surface,
50}
51
52impl RunCtx {
53 fn new(surface: Surface) -> Self {
54 let run_id = std::time::SystemTime::now()
57 .duration_since(std::time::UNIX_EPOCH)
58 .map(|d| format!("{:x}{:x}", d.as_secs(), d.subsec_nanos()))
59 .unwrap_or_else(|_| format!("{:x}", std::process::id()));
60 Self { run_id, surface }
61 }
62
63 fn base_attrs(&self) -> serde_json::Value {
64 serde_json::json!({
65 "quickstart.run_id": self.run_id,
66 "quickstart.surface": self.surface.as_str(),
67 })
68 }
69}
70
71fn merge_attrs(base: serde_json::Value, extra: serde_json::Value) -> serde_json::Value {
74 let (mut base_map, extra_map) = match (base, extra) {
75 (serde_json::Value::Object(b), serde_json::Value::Object(e)) => (b, e),
76 (b, _) => return b,
77 };
78 for (k, v) in extra_map {
79 base_map.insert(k, v);
80 }
81 serde_json::Value::Object(base_map)
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct AppliedAgent {
86 pub alias: String,
87 pub model_provider: String,
88 pub risk_profile: String,
89 pub runtime_profile: String,
90 pub channels: Vec<String>,
91 pub memory_backend: String,
92}
93
94#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "snake_case")]
96pub enum QuickstartStep {
97 ModelProvider,
98 RiskProfile,
99 RuntimeProfile,
100 Memory,
101 Channels,
102 PeerGroups,
103 Agent,
104}
105
106impl QuickstartStep {
107 #[must_use]
108 pub fn label(self) -> &'static str {
109 match self {
110 Self::ModelProvider => "Model provider",
111 Self::RiskProfile => "Risk profile",
112 Self::RuntimeProfile => "Runtime profile",
113 Self::Memory => "Memory",
114 Self::Channels => "Channels",
115 Self::PeerGroups => "Peer groups",
116 Self::Agent => "Agent",
117 }
118 }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122pub struct QuickstartError {
123 pub step: QuickstartStep,
124 pub field: String,
125 pub message: String,
126}
127
128impl QuickstartError {
129 fn new(step: QuickstartStep, field: impl Into<String>, message: impl Into<String>) -> Self {
130 Self {
131 step,
132 field: field.into(),
133 message: message.into(),
134 }
135 }
136}
137
138impl std::fmt::Display for QuickstartError {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 if self.field.is_empty() {
141 write!(f, "{:?}: {}", self.step, self.message)
142 } else {
143 write!(f, "{:?}.{}: {}", self.step, self.field, self.message)
144 }
145 }
146}
147
148pub fn validate_only(
149 submission: &BuilderSubmission,
150 config: &Config,
151) -> Result<(), Vec<QuickstartError>> {
152 validate_only_with_surface(submission, config, Surface::Web)
153}
154
155pub fn validate_only_with_surface(
156 submission: &BuilderSubmission,
157 config: &Config,
158 surface: Surface,
159) -> Result<(), Vec<QuickstartError>> {
160 let ctx = RunCtx::new(surface);
161 let mut staged = config.clone();
162 let mut errors = Vec::new();
163 let mut staged_files = Vec::new();
165 apply_into(
166 &mut staged,
167 submission,
168 &mut staged_files,
169 &mut errors,
170 Some(&ctx),
171 );
172 let ok = errors.is_empty();
173 let attrs = merge_attrs(
174 ctx.base_attrs(),
175 serde_json::json!({"error_count": errors.len()}),
176 );
177 let outcome = if ok {
178 ::zeroclaw_log::EventOutcome::Success
179 } else {
180 ::zeroclaw_log::EventOutcome::Failure
181 };
182 if ok {
183 ::zeroclaw_log::record!(
184 INFO,
185 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Validate)
186 .with_outcome(outcome)
187 .with_attrs(attrs),
188 "quickstart: validate_only"
189 );
190 } else {
191 ::zeroclaw_log::record!(
192 WARN,
193 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Validate)
194 .with_outcome(outcome)
195 .with_attrs(attrs),
196 "quickstart: validate_only"
197 );
198 }
199 if ok { Ok(()) } else { Err(errors) }
200}
201
202pub async fn apply(
203 submission: BuilderSubmission,
204 config: &mut Config,
205) -> Result<AppliedAgent, Vec<QuickstartError>> {
206 apply_with_surface(submission, config, Surface::Web).await
207}
208
209pub async fn apply_with_surface(
210 submission: BuilderSubmission,
211 config: &mut Config,
212 surface: Surface,
213) -> Result<AppliedAgent, Vec<QuickstartError>> {
214 let ctx = RunCtx::new(surface);
215 let started = std::time::Instant::now();
216
217 ::zeroclaw_log::record!(
218 INFO,
219 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Start)
220 .with_attrs(ctx.base_attrs()),
221 "quickstart: apply"
222 );
223
224 let mut errors = Vec::new();
225 let mut staged_files = Vec::new();
226 let applied = apply_into(
227 config,
228 &submission,
229 &mut staged_files,
230 &mut errors,
231 Some(&ctx),
232 );
233 if !errors.is_empty() {
234 ::zeroclaw_log::record!(
235 WARN,
236 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
237 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
238 .with_attrs(merge_attrs(
239 ctx.base_attrs(),
240 serde_json::json!({
241 "error_count": errors.len(),
242 "elapsed_ms": started.elapsed().as_millis() as u64,
243 }),
244 )),
245 "quickstart: apply rejected"
246 );
247 return Err(errors);
248 }
249 let applied = match applied {
250 Some(applied) => applied,
251 None => {
252 return Err(vec![QuickstartError::new(
253 QuickstartStep::Agent,
254 "apply",
255 "internal error: apply_into returned no result despite no validation errors",
256 )]);
257 }
258 };
259
260 config
261 .set_prop_persistent("onboard_state.quickstart_completed", "true")
262 .map_err(|err| {
263 vec![QuickstartError::new(
264 QuickstartStep::Agent,
265 "",
266 format!("failed to flip quickstart-completed: {err}"),
267 )]
268 })?;
269 ::zeroclaw_log::record!(
270 INFO,
271 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
272 merge_attrs(
273 ctx.base_attrs(),
274 serde_json::json!({"flag": "quickstart_completed"}),
275 )
276 ),
277 "quickstart: completion flag flipped"
278 );
279
280 let dirty_count = config.dirty_paths.len();
281 let write_started = std::time::Instant::now();
282 ::zeroclaw_log::record!(
283 DEBUG,
284 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Write).with_attrs(
285 merge_attrs(
286 ctx.base_attrs(),
287 serde_json::json!({"dirty_path_count": dirty_count}),
288 )
289 ),
290 "quickstart: persist start"
291 );
292 let write_result = config.save_dirty().await;
293 let write_ms = write_started.elapsed().as_millis() as u64;
294 match &write_result {
295 Ok(_) => ::zeroclaw_log::record!(
296 INFO,
297 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Write)
298 .with_outcome(::zeroclaw_log::EventOutcome::Success)
299 .with_attrs(merge_attrs(
300 ctx.base_attrs(),
301 serde_json::json!({
302 "dirty_path_count": dirty_count,
303 "elapsed_ms": write_ms,
304 }),
305 )),
306 "quickstart: persist complete"
307 ),
308 Err(err) => ::zeroclaw_log::record!(
309 WARN,
310 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Write)
311 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
312 .with_attrs(merge_attrs(
313 ctx.base_attrs(),
314 serde_json::json!({
315 "dirty_path_count": dirty_count,
316 "elapsed_ms": write_ms,
317 "error": err.to_string(),
318 }),
319 )),
320 "quickstart: persist failed"
321 ),
322 }
323 write_result.map_err(|err| {
324 vec![QuickstartError::new(
325 QuickstartStep::Agent,
326 "",
327 format!("failed to persist config: {err}"),
328 )]
329 })?;
330
331 let mut commit_errors = Vec::new();
335 commit_personality_files(staged_files, &mut commit_errors);
336 if !commit_errors.is_empty() {
337 return Err(commit_errors);
338 }
339
340 ::zeroclaw_log::record!(
341 INFO,
342 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Complete)
343 .with_outcome(::zeroclaw_log::EventOutcome::Success)
344 .with_attrs(merge_attrs(
345 ctx.base_attrs(),
346 serde_json::json!({
347 "agent": applied.alias,
348 "channels": applied.channels.len(),
349 "elapsed_ms": started.elapsed().as_millis() as u64,
350 }),
351 )),
352 "quickstart: apply complete"
353 );
354 Ok(applied)
355}
356
357pub fn record_dismissed(run_id: &str, surface: Surface, last_step: Option<QuickstartStep>) {
363 let last_step_str = last_step
364 .map(|s| match s {
365 QuickstartStep::ModelProvider => "model_provider",
366 QuickstartStep::RiskProfile => "risk_profile",
367 QuickstartStep::RuntimeProfile => "runtime_profile",
368 QuickstartStep::Memory => "memory",
369 QuickstartStep::Channels => "channels",
370 QuickstartStep::PeerGroups => "peer_groups",
371 QuickstartStep::Agent => "agent",
372 })
373 .unwrap_or("none");
374 ::zeroclaw_log::record!(
375 INFO,
376 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
377 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
378 .with_attrs(::serde_json::json!({
379 "quickstart.run_id": run_id,
380 "quickstart.surface": surface.as_str(),
381 "last_step": last_step_str,
382 "dismissed": true,
383 })),
384 "quickstart: dismissed"
385 );
386}
387
388pub fn should_auto_launch(config: &Config) -> bool {
392 !config.onboard_state.quickstart_completed && config.agents.is_empty()
393}
394
395#[derive(Debug, Clone, serde::Serialize)]
402#[serde(rename_all = "snake_case")]
403pub struct QuickstartState {
404 pub quickstart_completed: bool,
405 pub agents: Vec<String>,
406 pub risk_profiles: Vec<String>,
407 pub runtime_profiles: Vec<String>,
408 pub model_providers: Vec<String>,
410 pub channels: Vec<String>,
412 #[serde(default)]
418 pub unassigned_channels: Vec<String>,
419 pub storage: Vec<String>,
421 pub model_provider_types: Vec<QuickstartTypeOption>,
427 pub channel_types: Vec<QuickstartTypeOption>,
437 pub risk_presets: &'static [zeroclaw_config::presets::RiskPreset],
439 pub runtime_presets: &'static [zeroclaw_config::presets::RuntimePreset],
441 pub memory_kinds: Vec<String>,
443 pub personality_files: &'static [&'static str],
446}
447
448#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
453#[serde(rename_all = "snake_case")]
454pub struct QuickstartTypeOption {
455 pub kind: String,
457 pub display_name: String,
459 pub local: bool,
463}
464
465pub fn snapshot_state(cfg: &Config) -> QuickstartState {
475 let model_provider_types = zeroclaw_providers::list_model_providers()
476 .into_iter()
477 .map(|info| QuickstartTypeOption {
478 kind: info.name.to_string(),
479 display_name: info.display_name.to_string(),
480 local: info.local,
481 })
482 .collect();
483 let channel_types = build_channel_type_options(&cfg.channels);
494 QuickstartState {
495 quickstart_completed: cfg.onboard_state.quickstart_completed,
496 agents: cfg.agents.keys().cloned().collect(),
497 risk_profiles: cfg.risk_profiles.keys().cloned().collect(),
498 runtime_profiles: cfg.runtime_profiles.keys().cloned().collect(),
499 model_providers: cfg
500 .providers
501 .models
502 .iter_entries()
503 .map(|(family, alias, _)| format!("{family}.{alias}"))
504 .collect(),
505 channels: collect_aliased_refs(&cfg.channels),
506 unassigned_channels: collect_aliased_refs(&cfg.channels)
512 .into_iter()
513 .filter(|ch| cfg.agent_for_channel(ch).is_none())
514 .collect(),
515 storage: collect_aliased_refs(&cfg.storage),
516 model_provider_types,
517 channel_types,
518 risk_presets: zeroclaw_config::presets::RISK_PRESETS,
519 runtime_presets: zeroclaw_config::presets::RUNTIME_PRESETS,
520 memory_kinds: memory_kind_keys(),
521 personality_files: crate::agent::personality::EDITABLE_PERSONALITY_FILES,
522 }
523}
524
525fn memory_kind_keys() -> Vec<String> {
529 use zeroclaw_config::multi_agent::MemoryBackendKind as M;
530 [
531 M::Sqlite,
532 M::Markdown,
533 M::Postgres,
534 M::Qdrant,
535 M::Lucid,
536 M::None,
537 ]
538 .into_iter()
539 .map(|k| {
540 match k {
544 M::Sqlite | M::Markdown | M::Postgres | M::Qdrant | M::Lucid | M::None => (),
545 }
546 serde_json::to_value(k)
547 .ok()
548 .and_then(|v| v.as_str().map(str::to_string))
549 .unwrap_or_default()
550 })
551 .collect()
552}
553
554fn build_channel_type_options(
561 channels_cfg: &zeroclaw_config::schema::ChannelsConfig,
562) -> Vec<QuickstartTypeOption> {
563 channels_cfg
564 .channels()
565 .into_iter()
566 .map(|info| QuickstartTypeOption {
567 kind: info.kind.to_string(),
568 display_name: info.name.to_string(),
569 local: false,
570 })
571 .collect()
572}
573
574fn collect_aliased_refs<T: serde::Serialize>(value: &T) -> Vec<String> {
579 let mut out = Vec::new();
580 let Ok(serde_json::Value::Object(map)) = serde_json::to_value(value) else {
581 return out;
582 };
583 for (family, subvalue) in map {
584 if let serde_json::Value::Object(entries) = subvalue {
585 for alias in entries.keys() {
586 out.push(format!("{family}.{alias}"));
587 }
588 }
589 }
590 out.sort();
591 out
592}
593
594#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
598#[serde(rename_all = "snake_case")]
599pub enum FieldSection {
600 ModelProvider,
601 Channel,
602 PeerGroup,
603}
604
605#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
613#[serde(rename_all = "snake_case")]
614pub struct FieldDescriptor {
615 pub key: String,
618 pub label: String,
620 pub help: String,
622 pub kind: zeroclaw_config::traits::PropKind,
625 pub is_secret: bool,
627 pub enum_variants: Option<Vec<String>>,
629 pub required: bool,
633 pub default: Option<String>,
637}
638
639pub fn field_shape(section: FieldSection, type_key: &str) -> Vec<FieldDescriptor> {
644 const SYNTHETIC_ALIAS: &str = "qs0probe";
651 let (section_path, essentials) = match section {
652 FieldSection::ModelProvider => (
653 format!("providers.models.{type_key}"),
654 MODEL_PROVIDER_ESSENTIALS,
655 ),
656 FieldSection::Channel => (format!("channels.{type_key}"), CHANNEL_ESSENTIALS),
657 FieldSection::PeerGroup => (format!("peer-groups.{type_key}"), PEER_GROUP_ESSENTIALS),
658 };
659
660 let mut probe = Config::default();
664 if probe
665 .create_map_key(§ion_path, SYNTHETIC_ALIAS)
666 .is_err()
667 {
668 return Vec::new();
669 }
670 let leaf_prefix = format!("{section_path}.{SYNTHETIC_ALIAS}.");
671
672 let mut out = Vec::new();
673 for info in probe.prop_fields() {
674 let Some(field_path) = info.name.strip_prefix(&leaf_prefix) else {
675 continue;
676 };
677 if !essentials.contains(&field_path) {
678 continue;
679 }
680 let default = if info.is_secret {
688 None
689 } else {
690 let raw = info.display_value.trim();
691 if raw.is_empty() || raw == zeroclaw_config::traits::UNSET_DISPLAY {
692 None
693 } else {
694 Some(raw.to_string())
695 }
696 };
697 out.push(FieldDescriptor {
698 key: field_path.to_string(),
699 label: kebab_to_snake(field_path),
700 help: info.description.trim().to_string(),
701 kind: info.kind,
702 is_secret: info.is_secret,
703 enum_variants: info.enum_variants.map(|f| f()),
704 required: !matches!(
714 field_path,
715 "uri" | "api_key" | "requires_openai_auth" | "wire_api"
716 ),
717 default,
718 });
719 }
720 out.sort_by_key(|d| {
721 essentials
722 .iter()
723 .position(|k| *k == d.key.as_str())
724 .unwrap_or(usize::MAX)
725 });
726 out
727}
728
729const MODEL_PROVIDER_ESSENTIALS: &[&str] = &[
734 "model",
735 "api_key",
736 "uri",
737 "requires_openai_auth",
738 "wire_api",
739];
740const CHANNEL_ESSENTIALS: &[&str] = &["bot_token", "token", "webhook_url", "allowed_users"];
741const PEER_GROUP_ESSENTIALS: &[&str] = &["channel", "external_peers", "agents", "ignore"];
742
743const FORCED_RUNTIME_PRESET: &str = "unbounded";
746
747fn apply_into(
748 config: &mut Config,
749 submission: &BuilderSubmission,
750 staged_files: &mut Vec<StagedPersonalityWrite>,
751 errors: &mut Vec<QuickstartError>,
752 ctx: Option<&RunCtx>,
753) -> Option<AppliedAgent> {
754 let provider_ref = apply_model_provider(config, &submission.model_provider, errors)?;
755 emit_selector_pick(
756 ctx,
757 "model_provider",
758 selector_mode(&submission.model_provider),
759 &provider_ref,
760 );
761
762 let risk_alias = apply_named_preset(
763 config,
764 &submission.risk_profile,
765 QuickstartStep::RiskProfile,
766 risk_preset_keys,
767 write_risk_preset,
768 errors,
769 )?;
770 emit_selector_pick(
771 ctx,
772 "risk_profile",
773 selector_mode(&submission.risk_profile),
774 &risk_alias,
775 );
776
777 let runtime_alias = match write_runtime_preset(config, FORCED_RUNTIME_PRESET) {
778 Ok(alias) => alias,
779 Err(msg) => {
780 errors.push(QuickstartError::new(
781 QuickstartStep::RuntimeProfile,
782 "",
783 msg,
784 ));
785 return None;
786 }
787 };
788 emit_selector_pick(
789 ctx,
790 "runtime_profile",
791 selector_mode(&submission.runtime_profile),
792 &runtime_alias,
793 );
794
795 let memory_backend = apply_memory(config, &submission.memory, errors)?;
796 emit_selector_pick(
797 ctx,
798 "memory",
799 selector_mode(&submission.memory),
800 &memory_backend,
801 );
802
803 let channel_refs = apply_channels(config, &submission.channels, errors);
804 if let Some(ctx) = ctx {
805 ::zeroclaw_log::record!(
806 DEBUG,
807 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
808 merge_attrs(
809 ctx.base_attrs(),
810 serde_json::json!({
811 "selector": "channels",
812 "count": channel_refs.len(),
813 }),
814 )
815 ),
816 "quickstart: selector channels"
817 );
818 }
819
820 if !errors.is_empty() {
821 return None;
822 }
823 let alias = apply_agent(
824 config,
825 &submission.agent,
826 &provider_ref,
827 &risk_alias,
828 &runtime_alias,
829 &channel_refs,
830 errors,
831 )?;
832 emit_selector_pick(ctx, "agent", "create_new", &alias);
833
834 let peer_group_refs = apply_peer_groups(config, &submission.peer_groups, &channel_refs, errors);
835 if let Some(ctx) = ctx {
836 ::zeroclaw_log::record!(
837 DEBUG,
838 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
839 merge_attrs(
840 ctx.base_attrs(),
841 serde_json::json!({
842 "selector": "peer_groups",
843 "count": peer_group_refs.len(),
844 }),
845 )
846 ),
847 "quickstart: selector peer_groups"
848 );
849 }
850
851 apply_personality_files(
852 config,
853 &alias,
854 &submission.agent.personality_files,
855 staged_files,
856 errors,
857 );
858
859 materialize_default_skills_bundle(config);
860
861 if !errors.is_empty() {
862 return None;
863 }
864
865 Some(AppliedAgent {
866 alias,
867 model_provider: provider_ref,
868 risk_profile: risk_alias,
869 runtime_profile: runtime_alias,
870 channels: channel_refs,
871 memory_backend,
872 })
873}
874
875fn selector_mode<T>(choice: &SelectorChoice<T>) -> &'static str {
879 match choice {
880 SelectorChoice::Existing(_) => "use_existing",
881 SelectorChoice::Fresh(_) => "create_new",
882 }
883}
884
885fn emit_selector_pick(ctx: Option<&RunCtx>, selector: &str, mode: &str, value: &str) {
886 let Some(ctx) = ctx else { return };
887 ::zeroclaw_log::record!(
888 DEBUG,
889 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
890 merge_attrs(
891 ctx.base_attrs(),
892 serde_json::json!({
893 "selector": selector,
894 "mode": mode,
895 "value": value,
896 }),
897 )
898 ),
899 "quickstart: selector pick"
900 );
901}
902
903fn apply_model_provider(
906 config: &mut Config,
907 choice: &SelectorChoice<ModelProviderChoice>,
908 errors: &mut Vec<QuickstartError>,
909) -> Option<String> {
910 match choice {
911 SelectorChoice::Existing(reference) => {
912 let (family, alias) = match split_ref(reference) {
913 Some(parts) => parts,
914 None => {
915 errors.push(QuickstartError::new(
916 QuickstartStep::ModelProvider,
917 "",
918 format!("`{reference}` is not a `<type>.<alias>` reference"),
919 ));
920 return None;
921 }
922 };
923 if !section_has_alias(config, "providers.models", family, alias) {
924 errors.push(QuickstartError::new(
925 QuickstartStep::ModelProvider,
926 "",
927 format!("no `providers.models.{family}.{alias}` configured"),
928 ));
929 return None;
930 }
931 Some(reference.clone())
932 }
933 SelectorChoice::Fresh(choice) => {
934 if choice.provider_type.trim().is_empty()
935 || choice.alias.trim().is_empty()
936 || choice.model.trim().is_empty()
937 {
938 errors.push(QuickstartError::new(
939 QuickstartStep::ModelProvider,
940 "",
941 "provider type, alias, and model are required",
942 ));
943 return None;
944 }
945 let provider_type = choice.provider_type.trim();
951 let provider_type = match zeroclaw_providers::list_model_providers()
952 .into_iter()
953 .find(|info| info.name.eq_ignore_ascii_case(provider_type))
954 {
955 Some(info) => info.name.to_string(),
956 None => {
957 errors.push(QuickstartError::new(
958 QuickstartStep::ModelProvider,
959 "provider_type",
960 format!(
961 "unknown model provider type `{}` — pick one from the provider list",
962 choice.provider_type.trim()
963 ),
964 ));
965 return None;
966 }
967 };
968 if section_has_alias(config, "providers.models", &provider_type, &choice.alias) {
969 errors.push(QuickstartError::new(
970 QuickstartStep::ModelProvider,
971 "alias",
972 format!("alias `{}.{}` already exists", provider_type, choice.alias),
973 ));
974 return None;
975 }
976 let prefix = format!("providers.models.{}.{}", provider_type, choice.alias);
977 if let Err(err) = config.create_map_key(
978 &format!("providers.models.{}", provider_type),
979 &choice.alias,
980 ) {
981 errors.push(QuickstartError::new(
982 QuickstartStep::ModelProvider,
983 "provider_type",
984 err.to_string(),
985 ));
986 return None;
987 }
988 if let Err(err) = config.set_prop_persistent(&format!("{prefix}.model"), &choice.model)
989 {
990 errors.push(QuickstartError::new(
991 QuickstartStep::ModelProvider,
992 "model",
993 err.to_string(),
994 ));
995 return None;
996 }
997 let mut entries: Vec<(&String, &String)> = choice.fields.iter().collect();
1001 entries.sort_by(|a, b| a.0.cmp(b.0));
1002 for (key, value) in entries {
1003 if value.is_empty() {
1004 continue;
1005 }
1006 if let Err(err) = config.set_prop_persistent(&format!("{prefix}.{key}"), value) {
1007 errors.push(QuickstartError::new(
1008 QuickstartStep::ModelProvider,
1009 zeroclaw_config::helpers::kebab_to_snake(key),
1010 err.to_string(),
1011 ));
1012 return None;
1013 }
1014 }
1015 Some(format!("{}.{}", provider_type, choice.alias))
1016 }
1017 }
1018}
1019
1020fn apply_named_preset<K, W>(
1023 config: &mut Config,
1024 choice: &SelectorChoice<String>,
1025 step: QuickstartStep,
1026 list_existing: K,
1027 write_preset: W,
1028 errors: &mut Vec<QuickstartError>,
1029) -> Option<String>
1030where
1031 K: Fn(&Config) -> Vec<String>,
1032 W: Fn(&mut Config, &str) -> Result<String, String>,
1033{
1034 match choice {
1035 SelectorChoice::Existing(alias) => {
1036 if list_existing(config).iter().any(|a| a == alias) {
1037 Some(alias.clone())
1038 } else {
1039 errors.push(QuickstartError::new(
1040 step,
1041 "",
1042 format!("no `{alias}` profile configured"),
1043 ));
1044 None
1045 }
1046 }
1047 SelectorChoice::Fresh(preset_name) => match write_preset(config, preset_name) {
1048 Ok(alias) => Some(alias),
1049 Err(msg) => {
1050 errors.push(QuickstartError::new(step, "", msg));
1051 None
1052 }
1053 },
1054 }
1055}
1056
1057fn risk_preset_keys(config: &Config) -> Vec<String> {
1058 config.risk_profiles.keys().cloned().collect()
1059}
1060
1061fn write_risk_preset(config: &mut Config, preset_name: &str) -> Result<String, String> {
1062 let preset =
1063 risk_preset(preset_name).ok_or_else(|| format!("unknown risk preset `{preset_name}`"))?;
1064 if config.risk_profiles.contains_key(preset.preset_name) {
1067 return Ok(preset.preset_name.to_string());
1068 }
1069 config
1070 .create_map_key("risk_profiles", preset.preset_name)
1071 .map_err(|e| e.to_string())?;
1072 config
1073 .risk_profiles
1074 .insert(preset.preset_name.to_string(), (preset.values)());
1075 config.mark_dirty(&format!("risk_profiles.{}", preset.preset_name));
1076 Ok(preset.preset_name.to_string())
1077}
1078
1079fn write_runtime_preset(config: &mut Config, preset_name: &str) -> Result<String, String> {
1080 let preset = runtime_preset(preset_name)
1081 .ok_or_else(|| format!("unknown runtime preset `{preset_name}`"))?;
1082 if config.runtime_profiles.contains_key(preset.preset_name) {
1084 return Ok(preset.preset_name.to_string());
1085 }
1086 config
1087 .create_map_key("runtime_profiles", preset.preset_name)
1088 .map_err(|e| e.to_string())?;
1089 config
1090 .runtime_profiles
1091 .insert(preset.preset_name.to_string(), (preset.values)());
1092 config.mark_dirty(&format!("runtime_profiles.{}", preset.preset_name));
1093 Ok(preset.preset_name.to_string())
1094}
1095
1096fn apply_memory(
1099 config: &mut Config,
1100 choice: &SelectorChoice<MemoryChoice>,
1101 errors: &mut Vec<QuickstartError>,
1102) -> Option<String> {
1103 match choice {
1104 SelectorChoice::Existing(reference) => {
1105 let (family, alias) = match split_ref(reference) {
1106 Some(parts) => parts,
1107 None => {
1108 errors.push(QuickstartError::new(
1109 QuickstartStep::Memory,
1110 "",
1111 format!("`{reference}` is not a `<type>.<alias>` reference"),
1112 ));
1113 return None;
1114 }
1115 };
1116 if !section_has_alias(config, "storage", family, alias) {
1117 errors.push(QuickstartError::new(
1118 QuickstartStep::Memory,
1119 "",
1120 format!("no `storage.{family}.{alias}` configured"),
1121 ));
1122 return None;
1123 }
1124 if let Err(err) = config.set_prop_persistent("memory.backend", reference) {
1125 errors.push(QuickstartError::new(
1126 QuickstartStep::Memory,
1127 "backend",
1128 err.to_string(),
1129 ));
1130 return None;
1131 }
1132 Some(reference.clone())
1133 }
1134 SelectorChoice::Fresh(kind) => {
1135 let kind_name = serde_json::to_value(kind)
1142 .ok()
1143 .and_then(|v| v.as_str().map(str::to_string))
1144 .unwrap_or_else(|| format!("{kind:?}").to_lowercase());
1145 if matches!(kind, MemoryChoice::None) {
1146 if let Err(err) = config.set_prop_persistent("memory.backend", "none") {
1147 errors.push(QuickstartError::new(
1148 QuickstartStep::Memory,
1149 "backend",
1150 err.to_string(),
1151 ));
1152 return None;
1153 }
1154 return Some("none".to_string());
1155 }
1156 let backend_ref = format!("{kind_name}.{kind_name}");
1157 let parent_path = format!("storage.{kind_name}");
1158 if let Err(err) = config.create_map_key(&parent_path, &kind_name) {
1159 errors.push(QuickstartError::new(
1160 QuickstartStep::Memory,
1161 "",
1162 err.to_string(),
1163 ));
1164 return None;
1165 }
1166 if let Err(err) = config.set_prop_persistent("memory.backend", &backend_ref) {
1167 errors.push(QuickstartError::new(
1168 QuickstartStep::Memory,
1169 "backend",
1170 err.to_string(),
1171 ));
1172 return None;
1173 }
1174 Some(backend_ref)
1175 }
1176 }
1177}
1178
1179fn apply_channels(
1182 config: &mut Config,
1183 channels: &[SelectorChoice<ChannelQuickStart>],
1184 errors: &mut Vec<QuickstartError>,
1185) -> Vec<String> {
1186 let mut refs = Vec::with_capacity(channels.len());
1187 for (idx, ch) in channels.iter().enumerate() {
1188 match ch {
1189 SelectorChoice::Existing(reference) => {
1190 if let Some((family, alias)) = split_ref(reference) {
1191 if !channel_exists(config, family, alias) {
1192 errors.push(QuickstartError::new(
1193 QuickstartStep::Channels,
1194 format!("channels[{idx}]"),
1195 format!("no `channels.{family}.{alias}` configured"),
1196 ));
1197 continue;
1198 }
1199 if let Some(owner) = config.agent_for_channel(reference) {
1202 errors.push(QuickstartError::new(
1203 QuickstartStep::Channels,
1204 format!("channels[{idx}]"),
1205 format!("channel `{reference}` is already bound to agent `{owner}`"),
1206 ));
1207 continue;
1208 }
1209 refs.push(reference.clone());
1210 } else {
1211 errors.push(QuickstartError::new(
1212 QuickstartStep::Channels,
1213 format!("channels[{idx}]"),
1214 format!("`{reference}` is not a `<type>.<alias>` reference"),
1215 ));
1216 }
1217 }
1218 SelectorChoice::Fresh(entry) => {
1219 if entry.channel_type.trim().is_empty() || entry.alias.trim().is_empty() {
1220 errors.push(QuickstartError::new(
1221 QuickstartStep::Channels,
1222 format!("channels[{idx}]"),
1223 "channel type and alias are required",
1224 ));
1225 continue;
1226 }
1227 if channel_exists(config, &entry.channel_type, &entry.alias) {
1228 errors.push(QuickstartError::new(
1229 QuickstartStep::Channels,
1230 format!("channels[{idx}].alias"),
1231 format!(
1232 "alias `{}.{}` already exists",
1233 entry.channel_type, entry.alias
1234 ),
1235 ));
1236 continue;
1237 }
1238 if let Err(err) =
1239 config.create_map_key(&format!("channels.{}", entry.channel_type), &entry.alias)
1240 {
1241 errors.push(QuickstartError::new(
1242 QuickstartStep::Channels,
1243 format!("channels[{idx}].channel_type"),
1244 err.to_string(),
1245 ));
1246 continue;
1247 }
1248 let token_path =
1249 format!("channels.{}.{}.bot_token", entry.channel_type, entry.alias);
1250 if let Some(tok) = &entry.token {
1251 if let Err(err) = config.set_prop_persistent(&token_path, tok) {
1252 errors.push(QuickstartError::new(
1253 QuickstartStep::Channels,
1254 format!("channels[{idx}].token"),
1255 err.to_string(),
1256 ));
1257 continue;
1258 }
1259 } else {
1260 let enabled_path =
1265 format!("channels.{}.{}.enabled", entry.channel_type, entry.alias);
1266 if let Err(err) = config.set_prop_persistent(&enabled_path, "true") {
1267 errors.push(QuickstartError::new(
1268 QuickstartStep::Channels,
1269 format!("channels[{idx}]"),
1270 err.to_string(),
1271 ));
1272 continue;
1273 }
1274 }
1275 refs.push(format!("{}.{}", entry.channel_type, entry.alias));
1276 }
1277 }
1278 }
1279 refs
1280}
1281
1282fn channel_exists(config: &Config, channel_type: &str, alias: &str) -> bool {
1283 let probe = format!("channels.{channel_type}.{alias}.enabled");
1284 config.get_prop(&probe).is_ok()
1285}
1286
1287fn apply_peer_groups(
1290 config: &mut Config,
1291 peer_groups: &[zeroclaw_config::presets::QuickstartPeerGroup],
1292 staged_channel_refs: &[String],
1293 errors: &mut Vec<QuickstartError>,
1294) -> Vec<String> {
1295 let mut refs = Vec::with_capacity(peer_groups.len());
1296 for (idx, pg) in peer_groups.iter().enumerate() {
1297 if pg.name.trim().is_empty() {
1298 errors.push(QuickstartError::new(
1299 QuickstartStep::Channels,
1300 format!("peer_groups[{idx}].name"),
1301 "peer-group name is required",
1302 ));
1303 continue;
1304 }
1305 if pg.channel.trim().is_empty() {
1306 errors.push(QuickstartError::new(
1307 QuickstartStep::Channels,
1308 format!("peer_groups[{idx}].channel"),
1309 "peer-group channel ref is required",
1310 ));
1311 continue;
1312 }
1313 let staged_match = staged_channel_refs.iter().any(|r| r == &pg.channel);
1316 let configured_match = match split_ref(&pg.channel) {
1317 Some((family, alias)) => channel_exists(config, family, alias),
1318 None => false,
1319 };
1320 if !staged_match && !configured_match {
1321 errors.push(QuickstartError::new(
1322 QuickstartStep::Channels,
1323 format!("peer_groups[{idx}].channel"),
1324 format!(
1325 "peer-group `{}` references unknown channel `{}`",
1326 pg.name, pg.channel
1327 ),
1328 ));
1329 continue;
1330 }
1331 if config.peer_groups.contains_key(&pg.name) {
1334 errors.push(QuickstartError::new(
1335 QuickstartStep::Channels,
1336 format!("peer_groups[{idx}].name"),
1337 format!("peer-group `{}` already exists", pg.name),
1338 ));
1339 continue;
1340 }
1341 if let Err(err) = config.create_map_key("peer-groups", &pg.name) {
1342 errors.push(QuickstartError::new(
1343 QuickstartStep::Channels,
1344 format!("peer_groups[{idx}]"),
1345 err.to_string(),
1346 ));
1347 continue;
1348 }
1349 let prefix = format!("peer-groups.{}", pg.name);
1350 if let Err(err) = config.set_prop_persistent(&format!("{prefix}.channel"), &pg.channel) {
1351 errors.push(QuickstartError::new(
1352 QuickstartStep::Channels,
1353 format!("peer_groups[{idx}].channel"),
1354 err.to_string(),
1355 ));
1356 continue;
1357 }
1358 if !pg.external_peers.is_empty() {
1359 let joined = pg
1360 .external_peers
1361 .iter()
1362 .map(|s| s.as_str())
1363 .collect::<Vec<_>>()
1364 .join("\n");
1365 if let Err(err) =
1366 config.set_prop_persistent(&format!("{prefix}.external_peers"), &joined)
1367 {
1368 errors.push(QuickstartError::new(
1369 QuickstartStep::Channels,
1370 format!("peer_groups[{idx}].external_peers"),
1371 err.to_string(),
1372 ));
1373 continue;
1374 }
1375 }
1376 if !pg.ignore.is_empty() {
1377 let joined = pg
1378 .ignore
1379 .iter()
1380 .map(|s| s.as_str())
1381 .collect::<Vec<_>>()
1382 .join("\n");
1383 if let Err(err) = config.set_prop_persistent(&format!("{prefix}.ignore"), &joined) {
1384 errors.push(QuickstartError::new(
1385 QuickstartStep::Channels,
1386 format!("peer_groups[{idx}].ignore"),
1387 err.to_string(),
1388 ));
1389 continue;
1390 }
1391 }
1392 refs.push(pg.name.clone());
1393 }
1394 refs
1395}
1396
1397struct StagedPersonalityWrite {
1403 tempfile: tempfile::NamedTempFile,
1404 dest: std::path::PathBuf,
1405}
1406
1407fn apply_personality_files(
1408 config: &Config,
1409 agent_alias: &str,
1410 files: &[zeroclaw_config::presets::QuickstartPersonalityFile],
1411 staged: &mut Vec<StagedPersonalityWrite>,
1412 errors: &mut Vec<QuickstartError>,
1413) {
1414 if files.is_empty() {
1415 return;
1416 }
1417 let workspace = config.agent_workspace_dir(agent_alias);
1418 if let Err(err) = std::fs::create_dir_all(&workspace) {
1419 errors.push(QuickstartError::new(
1420 QuickstartStep::Agent,
1421 "personality_files",
1422 format!("could not create agent workspace: {err}"),
1423 ));
1424 return;
1425 }
1426 for (idx, file) in files.iter().enumerate() {
1427 let trimmed = file.filename.trim();
1428 if trimmed.is_empty() {
1429 errors.push(QuickstartError::new(
1430 QuickstartStep::Agent,
1431 format!("personality_files[{idx}].filename"),
1432 "filename is required",
1433 ));
1434 continue;
1435 }
1436 if !crate::agent::personality::EDITABLE_PERSONALITY_FILES.contains(&trimmed) {
1437 errors.push(QuickstartError::new(
1438 QuickstartStep::Agent,
1439 format!("personality_files[{idx}].filename"),
1440 format!("`{trimmed}` is not an editable personality file"),
1441 ));
1442 continue;
1443 }
1444 if file.content.chars().count() > crate::agent::personality::MAX_FILE_CHARS {
1445 errors.push(QuickstartError::new(
1446 QuickstartStep::Agent,
1447 format!("personality_files[{idx}].content"),
1448 format!(
1449 "content exceeds {} char limit",
1450 crate::agent::personality::MAX_FILE_CHARS
1451 ),
1452 ));
1453 continue;
1454 }
1455 let mut tempfile = match tempfile::NamedTempFile::new_in(&workspace) {
1459 Ok(t) => t,
1460 Err(err) => {
1461 errors.push(QuickstartError::new(
1462 QuickstartStep::Agent,
1463 format!("personality_files[{idx}]"),
1464 format!("stage {trimmed} failed: {err}"),
1465 ));
1466 continue;
1467 }
1468 };
1469 if let Err(err) = std::io::Write::write_all(&mut tempfile, file.content.as_bytes()) {
1470 errors.push(QuickstartError::new(
1471 QuickstartStep::Agent,
1472 format!("personality_files[{idx}]"),
1473 format!("stage {trimmed} failed: {err}"),
1474 ));
1475 continue;
1476 }
1477 staged.push(StagedPersonalityWrite {
1478 tempfile,
1479 dest: workspace.join(trimmed),
1480 });
1481 }
1482}
1483
1484fn commit_personality_files(
1488 staged: Vec<StagedPersonalityWrite>,
1489 errors: &mut Vec<QuickstartError>,
1490) {
1491 for write in staged {
1492 if let Err(err) = write.tempfile.persist(&write.dest) {
1493 errors.push(QuickstartError::new(
1494 QuickstartStep::Agent,
1495 "personality_files",
1496 format!("write {} failed: {}", write.dest.display(), err.error),
1497 ));
1498 }
1499 }
1500}
1501
1502fn materialize_default_skills_bundle(config: &mut Config) {
1505 if !config.skill_bundles.is_empty() {
1506 return;
1507 }
1508 let _ = config.create_map_key("skill-bundles", "default");
1512}
1513
1514fn apply_agent(
1517 config: &mut Config,
1518 identity: &AgentIdentity,
1519 provider_ref: &str,
1520 risk_alias: &str,
1521 runtime_alias: &str,
1522 channel_refs: &[String],
1523 errors: &mut Vec<QuickstartError>,
1524) -> Option<String> {
1525 if identity.name.trim().is_empty() {
1526 errors.push(QuickstartError::new(
1527 QuickstartStep::Agent,
1528 "name",
1529 "agent name is required",
1530 ));
1531 return None;
1532 }
1533 if config.agents.contains_key(&identity.name) {
1534 errors.push(QuickstartError::new(
1535 QuickstartStep::Agent,
1536 "name",
1537 format!("agent `{}` already exists", identity.name),
1538 ));
1539 return None;
1540 }
1541
1542 let prefix = format!("agents.{}", identity.name);
1543 if let Err(err) = config.create_map_key("agents", &identity.name) {
1544 errors.push(QuickstartError::new(
1545 QuickstartStep::Agent,
1546 "name",
1547 err.to_string(),
1548 ));
1549 return None;
1550 }
1551 let writes: [(&str, &str); 3] = [
1552 ("model_provider", provider_ref),
1553 ("risk_profile", risk_alias),
1554 ("runtime_profile", runtime_alias),
1555 ];
1556 for (field, value) in writes {
1557 let path = format!("{prefix}.{field}");
1558 if let Err(err) = config.set_prop_persistent(&path, value) {
1559 errors.push(QuickstartError::new(
1560 QuickstartStep::Agent,
1561 field,
1562 err.to_string(),
1563 ));
1564 return None;
1565 }
1566 }
1567 if !channel_refs.is_empty() {
1568 let path = format!("{prefix}.channels");
1569 let json = serde_json::to_string(channel_refs).unwrap_or_else(|_| "[]".to_string());
1570 if let Err(err) = config.set_prop_persistent(&path, &json) {
1571 errors.push(QuickstartError::new(
1572 QuickstartStep::Agent,
1573 "channels",
1574 err.to_string(),
1575 ));
1576 return None;
1577 }
1578 }
1579 Some(identity.name.clone())
1580}
1581
1582fn split_ref(reference: &str) -> Option<(&str, &str)> {
1585 let (ty, alias) = reference.split_once('.')?;
1586 if ty.is_empty() || alias.is_empty() {
1587 None
1588 } else {
1589 Some((ty, alias))
1590 }
1591}
1592
1593fn section_has_alias(config: &Config, prefix: &str, family: &str, alias: &str) -> bool {
1599 for probe_field in ["enabled", "model", "uri"] {
1600 let probe = format!("{prefix}.{family}.{alias}.{probe_field}");
1601 if config.get_prop(&probe).is_ok() {
1602 return true;
1603 }
1604 }
1605 false
1606}
1607
1608pub async fn model_catalog(
1613 model_provider: &str,
1614) -> (
1615 Vec<String>,
1616 Option<std::collections::HashMap<String, zeroclaw_api::model_provider::ModelPricing>>,
1617 bool,
1618) {
1619 if let Ok(handle) = zeroclaw_providers::create_model_provider(model_provider, None)
1620 && let Ok(models) = handle.list_models_with_pricing().await
1621 && !models.is_empty()
1622 {
1623 let pricing: std::collections::HashMap<String, zeroclaw_api::model_provider::ModelPricing> =
1624 models
1625 .iter()
1626 .filter_map(|m| m.pricing.as_ref().map(|p| (m.id.clone(), p.clone())))
1627 .collect();
1628 let ids: Vec<String> = models.into_iter().map(|m| m.id).collect();
1629 let pricing = if pricing.is_empty() {
1630 None
1631 } else {
1632 Some(pricing)
1633 };
1634 return (ids, pricing, true);
1635 }
1636 match zeroclaw_providers::catalog::list_models_for_family(model_provider).await {
1637 Ok(models) if !models.is_empty() => (models, None, true),
1638 _ => (Vec::new(), None, false),
1639 }
1640}
1641
1642#[must_use]
1644pub fn model_provider_is_local(model_provider: &str) -> bool {
1645 zeroclaw_providers::list_model_providers()
1646 .iter()
1647 .find(|p| p.name == model_provider)
1648 .is_some_and(|p| p.local)
1649}
1650
1651#[cfg(test)]
1652mod tests {
1653 use super::*;
1654 use zeroclaw_config::presets::{
1655 AgentIdentity, BuilderSubmission, ChannelQuickStart, MemoryChoice, ModelProviderChoice,
1656 SelectorChoice,
1657 };
1658 use zeroclaw_config::schema::Config;
1659
1660 #[test]
1671 fn channel_type_options_cover_every_schema_channel() {
1672 let cfg = Config::default();
1673 let picker = build_channel_type_options(&cfg.channels);
1674 let schema = cfg.channels.channels();
1675 assert_eq!(
1676 picker.len(),
1677 schema.len(),
1678 "Quickstart channel-type picker count diverged from \
1679 ChannelsConfig::channels(); picker has {} rows, schema has {}",
1680 picker.len(),
1681 schema.len(),
1682 );
1683 for (picked, expected) in picker.iter().zip(schema.iter()) {
1684 assert_eq!(
1685 picked.kind, expected.kind,
1686 "kind mismatch at {} — picker `{}`, schema `{}`",
1687 picked.display_name, picked.kind, expected.kind,
1688 );
1689 assert_eq!(
1690 picked.display_name, expected.name,
1691 "display_name mismatch at `{}` — picker `{}`, schema `{}`",
1692 picked.kind, picked.display_name, expected.name,
1693 );
1694 }
1695 }
1696
1697 fn fresh_submission(agent_name: &str) -> BuilderSubmission {
1698 BuilderSubmission {
1699 model_provider: SelectorChoice::Fresh(ModelProviderChoice {
1700 provider_type: "anthropic".into(),
1701 alias: "anthropic".into(),
1702 model: "claude-sonnet-4-5".into(),
1703 fields: std::collections::HashMap::from([(
1704 "api_key".to_string(),
1705 "sk-test".to_string(),
1706 )]),
1707 }),
1708 risk_profile: SelectorChoice::Fresh("balanced".into()),
1709 runtime_profile: SelectorChoice::Fresh("balanced".into()),
1710 memory: SelectorChoice::Fresh(MemoryChoice::Sqlite),
1711 channels: vec![],
1712 peer_groups: vec![],
1713 agent: AgentIdentity {
1714 name: agent_name.into(),
1715 system_prompt: "You are helpful.".into(),
1716 personality_file: None,
1717 personality_files: vec![],
1718 },
1719 }
1720 }
1721
1722 #[test]
1723 fn apply_serializes_provider_fields_as_snake_case() {
1724 let mut cfg = Config::default();
1725 let submission = fresh_submission("bot");
1726 let mut staged = Vec::new();
1727 let mut errors = Vec::new();
1728 let applied = apply_into(&mut cfg, &submission, &mut staged, &mut errors, None);
1729 assert!(errors.is_empty(), "apply_into errors: {errors:?}");
1730 assert!(applied.is_some(), "apply_into should yield an agent");
1731 let toml = toml::to_string(&cfg).expect("serialize config");
1734 assert!(
1735 toml.contains("api_key"),
1736 "expected snake `api_key` in serialized config:\n{toml}"
1737 );
1738 assert!(
1739 !toml.contains("api-key"),
1740 "kebab `api-key` leaked into serialized config:\n{toml}"
1741 );
1742 }
1743
1744 #[test]
1745 fn apply_provider_type_trims_and_canonicalizes_whitespace() {
1746 let mut cfg = Config::default();
1750 let mut submission = fresh_submission("bot");
1751 submission.model_provider = SelectorChoice::Fresh(ModelProviderChoice {
1752 provider_type: " llamacpp ".into(),
1753 alias: "local".into(),
1754 model: "qwen2.5-coder".into(),
1755 fields: std::collections::HashMap::new(),
1756 });
1757 let mut staged = Vec::new();
1758 let mut errors = Vec::new();
1759 let applied = apply_into(&mut cfg, &submission, &mut staged, &mut errors, None);
1760 assert!(errors.is_empty(), "apply_into errors: {errors:?}");
1761 assert!(applied.is_some());
1762 assert!(
1763 cfg.providers.models.find("llamacpp", "local").is_some(),
1764 "expected providers.models.llamacpp.local to exist"
1765 );
1766 let agent = cfg.agents.get("bot").expect("agent created");
1767 assert_eq!(agent.model_provider.as_str(), "llamacpp.local");
1768 }
1769
1770 #[test]
1771 fn apply_provider_type_case_insensitive() {
1772 let mut cfg = Config::default();
1773 let mut submission = fresh_submission("bot");
1774 submission.model_provider = SelectorChoice::Fresh(ModelProviderChoice {
1775 provider_type: "Anthropic".into(),
1776 alias: "main".into(),
1777 model: "claude-sonnet-4-5".into(),
1778 fields: std::collections::HashMap::new(),
1779 });
1780 let mut staged = Vec::new();
1781 let mut errors = Vec::new();
1782 let applied = apply_into(&mut cfg, &submission, &mut staged, &mut errors, None);
1783 assert!(errors.is_empty(), "apply_into errors: {errors:?}");
1784 assert!(applied.is_some());
1785 assert!(cfg.providers.models.find("anthropic", "main").is_some());
1786 }
1787
1788 #[test]
1789 fn apply_unknown_provider_type_errors_clearly() {
1790 let mut cfg = Config::default();
1791 let mut submission = fresh_submission("bot");
1792 submission.model_provider = SelectorChoice::Fresh(ModelProviderChoice {
1793 provider_type: "not_a_real_provider".into(),
1794 alias: "x".into(),
1795 model: "m".into(),
1796 fields: std::collections::HashMap::new(),
1797 });
1798 let mut staged = Vec::new();
1799 let mut errors = Vec::new();
1800 let applied = apply_into(&mut cfg, &submission, &mut staged, &mut errors, None);
1801 assert!(applied.is_none());
1802 assert!(
1803 errors
1804 .iter()
1805 .any(|e| e.step == QuickstartStep::ModelProvider
1806 && e.message.contains("unknown model provider type")),
1807 "expected a clear unknown-provider error, got: {errors:?}"
1808 );
1809 }
1810
1811 #[test]
1812 fn validate_only_passes_on_fresh_submission() {
1813 let cfg = Config::default();
1814 let submission = fresh_submission("bot");
1815 validate_only(&submission, &cfg).expect("fresh submission validates");
1816 }
1817
1818 #[test]
1819 fn validate_only_rejects_blank_agent_name() {
1820 let cfg = Config::default();
1821 let submission = fresh_submission("");
1822 let errors = validate_only(&submission, &cfg).unwrap_err();
1823 assert!(
1824 errors
1825 .iter()
1826 .any(|e| e.step == QuickstartStep::Agent && e.field == "name")
1827 );
1828 }
1829
1830 #[test]
1831 fn validate_only_rejects_existing_agent_name() {
1832 let mut cfg = Config::default();
1833 cfg.agents.insert(
1834 "bot".into(),
1835 zeroclaw_config::schema::AliasedAgentConfig::default(),
1836 );
1837 let submission = fresh_submission("bot");
1838 let errors = validate_only(&submission, &cfg).unwrap_err();
1839 assert!(errors.iter().any(|e| e.step == QuickstartStep::Agent));
1840 }
1841
1842 #[test]
1843 fn validate_only_rejects_unknown_risk_preset() {
1844 let cfg = Config::default();
1845 let mut submission = fresh_submission("bot");
1846 submission.risk_profile = SelectorChoice::Fresh("does-not-exist".into());
1847 let errors = validate_only(&submission, &cfg).unwrap_err();
1848 assert!(errors.iter().any(|e| e.step == QuickstartStep::RiskProfile));
1849 }
1850
1851 #[test]
1852 fn validate_only_accepts_every_builtin_risk_preset() {
1853 let cfg = Config::default();
1854 for p in zeroclaw_config::presets::RISK_PRESETS {
1855 let mut submission = fresh_submission("bot");
1856 submission.risk_profile = SelectorChoice::Fresh(p.preset_name.into());
1857 validate_only(&submission, &cfg).unwrap_or_else(|e| {
1858 panic!("risk preset `{}` failed validate: {e:?}", p.preset_name)
1859 });
1860 }
1861 }
1862
1863 #[test]
1872 fn field_shape_returns_model_provider_rows_for_canonical_types() {
1873 for kind in ["anthropic", "openai", "ollama", "openrouter", "groq"] {
1874 let rows = super::field_shape(super::FieldSection::ModelProvider, kind);
1875 let keys: Vec<&str> = rows.iter().map(|r| r.key.as_str()).collect();
1876 assert!(
1877 keys.contains(&"model"),
1878 "field_shape for `{kind}` is missing `model` row; got {keys:?}",
1879 );
1880 assert!(
1881 keys.contains(&"api_key"),
1882 "field_shape for `{kind}` is missing `api_key` row; got {keys:?}",
1883 );
1884 }
1885 }
1886
1887 #[test]
1893 fn field_shape_openai_includes_codex_auth_fields() {
1894 let rows = super::field_shape(super::FieldSection::ModelProvider, "openai");
1895 let keys: Vec<&str> = rows.iter().map(|r| r.key.as_str()).collect();
1896 assert!(
1897 keys.contains(&"requires_openai_auth"),
1898 "field_shape for openai must include `requires_openai_auth` for Codex subscription; got {keys:?}",
1899 );
1900 assert!(
1901 keys.contains(&"wire_api"),
1902 "field_shape for openai must include `wire_api` for Codex subscription; got {keys:?}",
1903 );
1904 for row in &rows {
1906 if row.key == "requires_openai_auth" || row.key == "wire_api" {
1907 assert!(
1908 !row.required,
1909 "`{}` must be non-required in the Quickstart form",
1910 row.key
1911 );
1912 }
1913 }
1914 for row in &rows {
1919 assert_ne!(
1920 row.default.as_deref(),
1921 Some(zeroclaw_config::traits::UNSET_DISPLAY),
1922 "`{}` must not default to the <unset> placeholder",
1923 row.key
1924 );
1925 }
1926 }
1927
1928 #[test]
1932 fn field_shape_api_key_is_not_required() {
1933 for kind in ["openai", "ollama"] {
1934 let rows = super::field_shape(super::FieldSection::ModelProvider, kind);
1935 let api_key_row = rows.iter().find(|r| r.key == "api_key");
1936 assert!(
1937 api_key_row.is_some(),
1938 "field_shape for `{kind}` must include `api_key`",
1939 );
1940 assert!(
1941 !api_key_row.unwrap().required,
1942 "`api_key` must be non-required for `{kind}` (Codex subscription / local providers don't need one)",
1943 );
1944 }
1945 }
1946
1947 async fn apply_to_temp(submission: BuilderSubmission) -> (tempfile::TempDir, Config) {
1948 let dir = tempfile::tempdir().unwrap();
1949 let config = Config {
1950 config_path: dir.path().join("config.toml"),
1951 data_dir: dir.path().join("data"),
1952 ..Default::default()
1953 };
1954 config.save().await.unwrap();
1955 let mut config = config;
1956 super::apply(submission, &mut config)
1957 .await
1958 .expect("apply should succeed");
1959 (dir, config)
1960 }
1961
1962 fn reload(dir: &tempfile::TempDir) -> Config {
1963 let raw = std::fs::read_to_string(dir.path().join("config.toml")).unwrap();
1964 toml::from_str(&raw).expect("on-disk config must round-trip")
1965 }
1966
1967 #[tokio::test]
1968 async fn fresh_preset_profiles_persist_to_disk() {
1969 let (dir, applied) = apply_to_temp(fresh_submission("bot")).await;
1972 assert!(applied.risk_profiles.contains_key("balanced"));
1973 assert!(applied.runtime_profiles.contains_key("unbounded"));
1974 let reloaded = reload(&dir);
1975 assert!(
1976 reloaded.risk_profiles.contains_key("balanced"),
1977 "risk_profiles.balanced must survive save_dirty + reload, not dangle"
1978 );
1979 assert!(
1980 reloaded.runtime_profiles.contains_key("unbounded"),
1981 "runtime_profiles.unbounded must survive save_dirty + reload, not dangle"
1982 );
1983 let agent = reloaded.agents.get("bot").expect("agent persisted");
1984 assert_eq!(agent.risk_profile, "balanced");
1985 assert_eq!(agent.runtime_profile, "unbounded");
1986 }
1987
1988 #[tokio::test]
1989 async fn multiple_channels_all_bind_to_agent() {
1990 let mut submission = fresh_submission("bot");
1991 submission.channels = vec![
1992 SelectorChoice::Fresh(ChannelQuickStart {
1993 channel_type: "telegram".into(),
1994 alias: "tg".into(),
1995 token: Some("tok-a".into()),
1996 }),
1997 SelectorChoice::Fresh(ChannelQuickStart {
1998 channel_type: "discord".into(),
1999 alias: "dc".into(),
2000 token: Some("tok-b".into()),
2001 }),
2002 ];
2003 let (dir, _applied) = apply_to_temp(submission).await;
2004 let reloaded = reload(&dir);
2005 let agent = reloaded.agents.get("bot").expect("agent persisted");
2006 let bound: Vec<String> = agent.channels.iter().map(|c| c.to_string()).collect();
2007 assert!(
2008 bound.iter().any(|c| c.contains("tg")),
2009 "first channel must stay bound; got {bound:?}"
2010 );
2011 assert!(
2012 bound.iter().any(|c| c.contains("dc")),
2013 "second channel must also be bound; got {bound:?}"
2014 );
2015 assert_eq!(bound.len(), 2, "both channels bound, not just the last");
2016 }
2017}