1use std::collections::BTreeSet;
2use std::path::Path;
3
4use crate::config::{AppConfig, CronParseError, CronSchedule, KillSignal, LevelMatcher};
5use crate::secrets;
6use crate::values::UpDuration;
7
8use super::{NormalizeError, tilde, validate};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ResolvedApp {
13 pub(super) config: AppConfig,
14}
15
16impl ResolvedApp {
17 #[must_use]
19 pub fn config(&self) -> &AppConfig {
20 &self.config
21 }
22
23 #[must_use]
25 pub fn into_config(self) -> AppConfig {
26 self.config
27 }
28}
29
30pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
62 let home = std::env::home_dir();
63 let shep_home = crate::paths::shep_home(&|key| std::env::var(key).ok(), home.as_deref());
64 normalize_with_home(app, home.as_deref(), shep_home.as_deref())
65}
66
67pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
75 let mut seen = BTreeSet::new();
76 let resolved: Vec<ResolvedApp> = apps
77 .into_iter()
78 .map(|app| {
79 if !seen.insert(app.name.clone()) {
80 return Err(NormalizeError::DuplicateName(app.name));
81 }
82 normalize(app)
83 })
84 .collect::<Result<_, _>>()?;
85 let nodes: Vec<crate::config::graph::BootNode> = resolved
88 .iter()
89 .map(|app| crate::config::graph::BootNode {
90 name: app.config().name.clone(),
91 depends_on: app.config().depends_on.clone(),
92 kind: crate::config::graph::NodeKind::Sheep,
93 })
94 .collect();
95 if let Some(cycle) = crate::config::graph::plan(&nodes).cycles.into_iter().next() {
96 return Err(NormalizeError::DependencyCycle(cycle));
97 }
98 Ok(resolved)
99}
100
101pub(super) const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
111
112pub(super) const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
119
120const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
128
129pub fn normalize_with_home(
144 mut app: AppConfig,
145 home: Option<&Path>,
146 shep_home: Option<&Path>,
147) -> Result<ResolvedApp, NormalizeError> {
148 if app.name.is_empty() {
149 return Err(NormalizeError::MissingName);
150 }
151 if app.name.contains(['/', '\\', ':']) || app.name == "." || app.name == ".." {
152 return Err(NormalizeError::InvalidName(app.name));
153 }
154 if let Some(environment) = &app.environment
155 && (environment == secrets::ALL_ENVIRONMENTS || !secrets::is_name(environment))
156 {
157 return Err(NormalizeError::InvalidEnvironment {
158 name: app.name.clone(),
159 value: environment.clone(),
160 });
161 }
162 for var in ["SHEP_INSTANCE", "SHEP_NAME", "SHEP_ENVIRONMENT"] {
163 if app.env.contains_key(var) {
164 return Err(NormalizeError::ReservedEnvVar {
165 name: app.name.clone(),
166 var,
167 });
168 }
169 }
170 for (key, value) in &app.env {
171 validate::validate_template(&app.name, &format!("env.{key}"), value, shep_home)?;
172 }
173 for (index, value) in app.args.iter().enumerate() {
174 validate::validate_template(&app.name, &format!("args[{index}]"), value, shep_home)?;
175 }
176 for (field, value) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
177 if let Some(value) = value {
178 validate::validate_template(&app.name, field, value, shep_home)?;
179 if crate::config::template::holds_secret(value) {
180 return Err(NormalizeError::SecretInLogPath {
181 name: app.name.clone(),
182 field,
183 });
184 }
185 }
186 }
187 if app.script.is_empty() {
188 return Err(NormalizeError::MissingScript);
189 }
190 tilde::expand_paths(&mut app, home)?;
194 if app.instances == 0 {
195 return Err(NormalizeError::ZeroInstances);
196 }
197 if app.instances > 1 && !app.merge_logs {
200 for (field, path) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
201 if let Some(path) = path
206 && crate::config::template::render_positional(path, &app.name, 0, shep_home)
207 == crate::config::template::render_positional(path, &app.name, 1, shep_home)
208 {
209 return Err(NormalizeError::SharedLogPath {
210 name: app.name.clone(),
211 field,
212 });
213 }
214 }
215 }
216 if let Some(pattern) = &app.cron_restart {
217 CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
218 CronParseError::Pattern { pattern, reason } => {
219 NormalizeError::InvalidCron { pattern, reason }
220 }
221 CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
222 })?;
223 } else if let Some(tz_name) = &app.cron_timezone {
224 crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
227 NormalizeError::InvalidTimezone {
228 name: tz_name.clone(),
229 }
230 })?;
231 }
232 validate::validate_probe(
233 app.readiness_probe.as_ref(),
234 "readiness_probe",
235 MIN_READINESS_INTERVAL,
236 )?;
237 validate::validate_probe(
238 app.liveness_probe.as_ref(),
239 "liveness_probe",
240 MIN_LIVENESS_INTERVAL,
241 )?;
242 if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
243 return Err(NormalizeError::ZeroMaxMemory { name: app.name });
248 }
249 if let Some(name) = &app.kill_signal
250 && KillSignal::parse(name).is_none()
251 {
252 return Err(NormalizeError::InvalidKillSignal {
256 name: app.name,
257 value: name.clone(),
258 });
259 }
260 if app.action_timeout > MAX_ACTION_TIMEOUT {
261 return Err(NormalizeError::ActionTimeoutTooLong {
265 name: app.name,
266 value: app.action_timeout,
267 max: MAX_ACTION_TIMEOUT,
268 });
269 }
270 if app.watch && app.cwd.is_none() {
271 return Err(NormalizeError::WatchWithoutCwd { name: app.name });
276 }
277 if app.watch_delay == Some(UpDuration::from_millis(0)) {
278 return Err(NormalizeError::ZeroWatchDelay { name: app.name });
282 }
283 validate::validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
287 validate::validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
288 LevelMatcher::compile(&app.level_rules).map_err(|err| NormalizeError::InvalidLevelRule {
293 name: app.name.clone(),
294 reason: err.to_string(),
295 })?;
296 if app.dogs.keys().any(String::is_empty) {
297 return Err(NormalizeError::EmptyDogName { name: app.name });
298 }
299 let mut seen = BTreeSet::new();
300 let mut deduped = Vec::with_capacity(app.depends_on.len());
301 for target in &app.depends_on {
302 if target == &app.name {
303 return Err(NormalizeError::SelfDependency(app.name.clone()));
304 }
305 if target.contains(':') {
306 return Err(NormalizeError::InstanceDependency {
307 sheep: app.name.clone(),
308 target: target.clone(),
309 });
310 }
311 if seen.insert(target.clone()) {
312 deduped.push(target.clone());
313 }
314 }
315 app.depends_on = deduped;
316 Ok(ResolvedApp { config: app })
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::config::{LevelRule, LevelRuleError, LineLevel};
323
324 #[test]
328 fn reuse_port_loads_now_that_reload_reads_it() {
329 let mut app = AppConfig::minimal("web", "./server");
330 app.reuse_port = true;
331
332 let resolved = normalize(app).expect("reuse_port is implemented");
333 assert!(resolved.config().reuse_port);
334 }
335
336 #[test]
339 fn an_app_that_never_mentions_reuse_port_still_normalizes() {
340 let resolved = normalize(AppConfig::minimal("web", "./server"))
341 .expect("the common case must be untouched");
342 assert!(!resolved.config().reuse_port);
343 }
344 use crate::config::AppConfig;
345
346 #[test]
347 fn a_colon_in_a_name_is_refused_because_it_is_the_instance_separator() {
348 let err = normalize(AppConfig::minimal("web:2", "./srv")).unwrap_err();
349 assert_eq!(err, NormalizeError::InvalidName("web:2".to_string()));
350
351 let rendered = err.to_string();
352 assert!(rendered.contains(':'), "says which character: {rendered}");
353 assert!(
355 rendered.contains("`-`"),
356 "suggests the stand-in: {rendered}"
357 );
358 assert!(
359 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
360 "no em or en dash in copy a user reads: {rendered}"
361 );
362
363 assert!(normalize(AppConfig::minimal("web-2", "./srv")).is_ok());
364 }
365
366 #[test]
371 fn the_reserved_env_vars_are_refused_rather_than_overwritten() {
372 let advice = [
376 ("SHEP_INSTANCE", "or `{{instance}}` in your own variable"),
377 ("SHEP_NAME", "or `{{name}}` in your own variable"),
378 ("SHEP_ENVIRONMENT", "or set `environment` on the app"),
379 ];
380 for (var, fitting) in advice {
381 let mut app = AppConfig::minimal("web", "./srv");
382 app.env.insert(var.to_string(), "mine".to_string());
383 let err = normalize(app).unwrap_err();
384 let rendered = err.to_string();
385 assert!(rendered.contains(var), "names the variable: {rendered}");
386 assert!(
387 rendered.contains(fitting),
388 "{var} gets advice that fits it: {rendered}"
389 );
390 for (owner, unrelated) in advice {
391 assert!(
392 owner == var || !rendered.contains(unrelated),
393 "{var} carries {owner}'s advice: {rendered}"
394 );
395 }
396 assert_eq!(
399 rendered.contains("{{"),
400 fitting.contains("{{"),
401 "{var} offers a template only if its own advice does: {rendered}"
402 );
403 assert!(
404 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
405 "no em or en dash in copy a user reads: {rendered}"
406 );
407 }
408 }
409
410 #[test]
411 fn valid_minimal_config_normalizes() {
412 let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
413 assert_eq!(resolved.config().name, "web");
414 }
415
416 #[test]
417 fn names_that_reach_the_filesystem_are_rejected() {
418 for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
421 assert_eq!(
422 normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
423 NormalizeError::InvalidName(bad.to_string())
424 );
425 }
426 assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
427 }
428
429 #[test]
430 fn missing_name_and_script_are_distinct_errors() {
431 assert_eq!(
432 normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
433 NormalizeError::MissingName
434 );
435 assert_eq!(
436 normalize(AppConfig::minimal("web", "")).unwrap_err(),
437 NormalizeError::MissingScript
438 );
439 }
440
441 #[test]
442 fn zero_instances_rejected() {
443 let mut app = AppConfig::minimal("web", "./srv");
444 app.instances = 0;
445 assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
446 }
447
448 #[test]
449 fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
450 let mut app = AppConfig::minimal("web", "./srv");
452 app.cron_restart = Some("not a cron".to_string());
453 match normalize(app).unwrap_err() {
454 NormalizeError::InvalidCron { pattern, reason } => {
455 assert_eq!(pattern, "not a cron");
456 assert!(!reason.is_empty());
457 }
458 other => panic!("expected InvalidCron, got {other:?}"),
459 }
460 }
461
462 #[test]
463 fn five_tokens_of_garbage_cron_pattern_rejected() {
464 let mut app = AppConfig::minimal("web", "./srv");
468 app.cron_restart = Some("99 99 99 99 99".to_string());
469 match normalize(app).unwrap_err() {
470 NormalizeError::InvalidCron { pattern, .. } => {
471 assert_eq!(pattern, "99 99 99 99 99");
472 }
473 other => panic!("expected InvalidCron, got {other:?}"),
474 }
475 }
476
477 #[test]
478 fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
479 let mut app = AppConfig::minimal("web", "./srv");
484 app.cron_restart = Some("0 3 * * *".to_string());
485 app.cron_timezone = Some("Mars/Olympus".to_string());
486 match normalize(app).unwrap_err() {
487 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
488 other => panic!("expected InvalidTimezone, got {other:?}"),
489 }
490 }
491
492 #[test]
493 fn cron_timezone_validated_even_without_cron_restart() {
494 let mut app = AppConfig::minimal("web", "./srv");
498 app.cron_timezone = Some("Mars/Olympus".to_string());
499 match normalize(app).unwrap_err() {
500 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
501 other => panic!("expected InvalidTimezone, got {other:?}"),
502 }
503 }
504
505 #[test]
506 fn duplicate_names_rejected_across_a_flock() {
507 let apps = vec![
508 AppConfig::minimal("web", "./a"),
509 AppConfig::minimal("web", "./b"),
510 ];
511 assert_eq!(
512 normalize_all(apps).unwrap_err(),
513 NormalizeError::DuplicateName("web".to_string())
514 );
515 }
516
517 #[test]
518 fn a_cycle_inside_one_document_is_refused_and_named() {
519 let mut a = AppConfig::minimal("a", "./a");
521 a.depends_on = vec!["b".to_string()];
522 let mut b = AppConfig::minimal("b", "./b");
523 b.depends_on = vec!["a".to_string()];
524 match normalize_all(vec![a, b]) {
525 Err(NormalizeError::DependencyCycle(cycle)) => {
526 let rendered = NormalizeError::DependencyCycle(cycle).to_string();
527 assert!(rendered.contains("a"), "{rendered}");
528 assert!(rendered.contains("b"), "{rendered}");
529 assert!(
530 rendered.contains(" -> "),
531 "the path must be shown: {rendered}"
532 );
533 }
534 other => panic!("expected DependencyCycle, got {other:?}"),
535 }
536 }
537
538 #[test]
539 fn a_dependency_on_an_app_outside_the_document_is_accepted() {
540 let mut api = AppConfig::minimal("api", "./api");
543 api.depends_on = vec!["db-in-another-repo".to_string()];
544 normalize_all(vec![api]).expect("an unresolved name is not a document error");
545 }
546
547 #[test]
548 fn zero_max_memory_rejected() {
549 let mut app = AppConfig::minimal("web", "./srv");
554 app.max_memory = Some(crate::values::MemSize::from_bytes(0));
555 let err = normalize(app).unwrap_err();
556 assert_eq!(
557 err,
558 NormalizeError::ZeroMaxMemory {
559 name: "web".to_string()
560 }
561 );
562 assert!(err.to_string().contains("max_memory"), "{err}");
565 }
566
567 #[test]
568 fn a_nonzero_max_memory_is_accepted() {
569 let mut app = AppConfig::minimal("web", "./srv");
573 app.max_memory = Some("512M".parse().unwrap());
574 assert!(normalize(app).is_ok());
575 }
576
577 #[test]
581 fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
582 let mut app = AppConfig::minimal("web", "./srv");
583 app.kill_signal = Some("SIGUSR1".to_string());
584
585 let err = normalize(app).unwrap_err();
586
587 assert_eq!(
588 err,
589 NormalizeError::InvalidKillSignal {
590 name: "web".to_string(),
591 value: "SIGUSR1".to_string(),
592 }
593 );
594 let rendered = err.to_string();
597 assert!(rendered.contains("SIGUSR1"), "{rendered}");
598 assert!(rendered.contains("SIGTERM"), "{rendered}");
599 assert!(rendered.contains("SIGUSR2"), "{rendered}");
600 }
601
602 #[test]
606 fn every_spelling_the_daemon_already_accepted_still_normalizes() {
607 for name in [
608 "SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
609 "USR2", "sigusr2",
610 ] {
611 let mut app = AppConfig::minimal("web", "./srv");
612 app.kill_signal = Some(name.to_string());
613 assert!(
614 normalize(app).is_ok(),
615 "`{name}` was accepted before this task and must still be"
616 );
617 }
618 }
619
620 #[test]
624 fn an_unset_kill_signal_is_not_a_config_error() {
625 let app = AppConfig::minimal("web", "./srv");
626 assert!(app.kill_signal.is_none());
627 assert!(normalize(app).is_ok());
628 }
629
630 #[test]
631 fn a_sheep_cannot_claim_the_all_environment() {
632 let mut app = AppConfig::minimal("web", "./srv");
635 app.environment = Some("all".to_string());
636 let err = normalize(app).unwrap_err();
637 assert!(err.to_string().contains("all"), "{err}");
638 }
639
640 #[test]
641 fn an_environment_outside_the_grammar_is_refused() {
642 for bad in ["", "has space", "has/slash"] {
643 let mut app = AppConfig::minimal("web", "./srv");
644 app.environment = Some(bad.to_string());
645 assert!(normalize(app).is_err(), "{bad:?} must be refused");
646 }
647 }
648
649 #[test]
650 fn action_timeout_past_the_ceiling_is_rejected() {
651 let mut app = AppConfig::minimal("web", "./srv");
656 app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
657 let err = normalize(app).unwrap_err();
658 assert_eq!(
659 err,
660 NormalizeError::ActionTimeoutTooLong {
661 name: "web".to_string(),
662 value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
663 max: MAX_ACTION_TIMEOUT,
664 }
665 );
666 assert!(err.to_string().contains("action_timeout"), "{err}");
669 }
670
671 #[test]
672 fn action_timeout_at_the_ceiling_is_accepted() {
673 let mut app = AppConfig::minimal("web", "./srv");
677 app.action_timeout = MAX_ACTION_TIMEOUT;
678 assert!(normalize(app).is_ok());
679 }
680
681 #[test]
682 fn the_default_action_timeout_is_accepted() {
683 assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
687 }
688
689 #[test]
690 fn zero_watch_delay_rejected() {
691 let mut app = AppConfig::minimal("web", "./srv");
696 app.watch = true;
697 app.cwd = Some("/srv/web".to_string());
698 app.watch_delay = Some(UpDuration::from_millis(0));
699 let err = normalize(app).unwrap_err();
700 assert_eq!(
701 err,
702 NormalizeError::ZeroWatchDelay {
703 name: "web".to_string()
704 }
705 );
706 assert!(err.to_string().contains("watch_delay"), "{err}");
709 }
710
711 #[test]
712 fn a_zero_watch_delay_is_rejected_with_watch_off() {
713 let mut app = AppConfig::minimal("web", "./srv");
718 app.watch_delay = Some(UpDuration::from_millis(0));
719 assert!(matches!(
720 normalize(app).unwrap_err(),
721 NormalizeError::ZeroWatchDelay { .. }
722 ));
723 }
724
725 #[test]
726 fn a_nonzero_watch_delay_is_accepted() {
727 let mut app = AppConfig::minimal("web", "./srv");
731 app.watch = true;
732 app.cwd = Some("/srv/web".to_string());
733 app.watch_delay = Some(UpDuration::from_millis(1));
734 assert!(normalize(app).is_ok());
735 }
736
737 #[test]
738 fn watch_true_without_cwd_rejected_naming_the_app() {
739 let mut app = AppConfig::minimal("web", "./srv");
743 app.watch = true;
744 let err = normalize(app).unwrap_err();
745 assert_eq!(
746 err,
747 NormalizeError::WatchWithoutCwd {
748 name: "web".to_string()
749 }
750 );
751 assert!(err.to_string().contains("no cwd to watch"), "{err}");
754 }
755
756 #[test]
757 fn watch_true_with_cwd_accepted() {
758 let mut app = AppConfig::minimal("web", "./srv");
761 app.watch = true;
762 app.cwd = Some("/srv/web".to_string());
763 assert!(normalize(app).is_ok());
764 }
765
766 #[test]
767 fn level_rules_survive_normalization_in_the_order_they_were_written() {
768 let mut app = AppConfig::minimal("web", "./srv");
769 app.level_rules = vec![
770 LevelRule {
771 pattern: r"\[ERROR\]".to_string(),
772 level: LineLevel::Error,
773 },
774 LevelRule {
775 pattern: r"\[WARN\]".to_string(),
776 level: LineLevel::Warn,
777 },
778 ];
779 let resolved = normalize(app.clone()).unwrap();
780 assert_eq!(resolved.config().level_rules, app.level_rules);
781 }
782
783 #[test]
787 fn a_level_rule_pattern_that_will_not_compile_is_rejected() {
788 let mut app = AppConfig::minimal("web", "./srv");
789 app.level_rules = vec![LevelRule {
790 pattern: "[unclosed".to_string(),
791 level: LineLevel::Error,
792 }];
793 let err = normalize(app).unwrap_err();
794 let rendered = err.to_string();
795 for expected in ["web", "[unclosed"] {
796 assert!(
797 rendered.contains(expected),
798 "{expected} missing: {rendered}"
799 );
800 }
801 }
802
803 #[test]
806 fn an_empty_level_rule_pattern_is_rejected() {
807 let mut app = AppConfig::minimal("web", "./srv");
808 app.level_rules = vec![LevelRule {
809 pattern: String::new(),
810 level: LineLevel::Warn,
811 }];
812 let err = normalize(app).unwrap_err();
813 assert_eq!(
814 err,
815 NormalizeError::InvalidLevelRule {
816 name: "web".to_string(),
817 reason: LevelRuleError::EmptyPattern {
818 level: LineLevel::Warn
819 }
820 .to_string(),
821 }
822 );
823 }
824
825 #[test]
826 fn watch_options_without_watch_or_cwd_accepted() {
827 let mut app = AppConfig::minimal("web", "./srv");
831 app.watch_options = vec!["src/**".to_string()];
832 assert!(normalize(app).is_ok());
833 }
834
835 #[test]
836 fn a_sheep_that_depends_on_itself_is_refused_by_name() {
837 let mut app = AppConfig::minimal("api", "./api");
840 app.depends_on = vec!["api".to_string()];
841 match normalize(app) {
842 Err(NormalizeError::SelfDependency(name)) => assert_eq!(name, "api"),
843 other => panic!("expected SelfDependency, got {other:?}"),
844 }
845 }
846
847 #[test]
848 fn a_dependency_on_one_instance_is_refused_naming_the_app_form() {
849 let mut app = AppConfig::minimal("api", "./api");
851 app.depends_on = vec!["db:2".to_string()];
852 match normalize(app) {
853 Err(NormalizeError::InstanceDependency { sheep, target }) => {
854 assert_eq!(sheep, "api");
855 assert_eq!(target, "db:2");
856 }
857 other => panic!("expected InstanceDependency, got {other:?}"),
858 }
859 let rendered = NormalizeError::InstanceDependency {
860 sheep: "api".to_string(),
861 target: "db:2".to_string(),
862 }
863 .to_string();
864 assert!(
865 rendered.contains("`db`"),
866 "the refusal must name the app-level form: {rendered}"
867 );
868 }
869
870 #[test]
871 fn duplicate_dependencies_dedupe_rather_than_refusing() {
872 let mut app = AppConfig::minimal("api", "./api");
875 app.depends_on = vec!["db".to_string(), "db".to_string()];
876 let resolved = normalize(app).expect("a repeated name is not an error");
877 assert_eq!(resolved.config().depends_on, vec!["db".to_string()]);
878 }
879
880 #[test]
881 fn an_ordinary_dependency_list_survives_normalize() {
882 let mut app = AppConfig::minimal("api", "./api");
884 app.depends_on = vec!["db".to_string(), "cache".to_string()];
885 let resolved = normalize(app).expect("an ordinary list normalizes");
886 assert_eq!(
887 resolved.config().depends_on,
888 vec!["db".to_string(), "cache".to_string()]
889 );
890 }
891
892 #[test]
893 fn an_empty_dog_name_is_refused_naming_the_sheep() {
894 let mut app = AppConfig::minimal("web", "./srv");
897 app.dogs.insert(
898 String::new(),
899 crate::config::DogTable::from(serde_json::Map::new()),
900 );
901 let err = normalize(app).unwrap_err();
902 assert_eq!(
903 err,
904 NormalizeError::EmptyDogName {
905 name: "web".to_string()
906 }
907 );
908 assert!(err.to_string().contains("web"));
909 }
910
911 #[test]
912 fn a_named_dog_table_survives_normalize() {
913 let mut app = AppConfig::minimal("web", "./srv");
916 let mut table = serde_json::Map::new();
917 table.insert("concurrency".to_string(), serde_json::json!(2));
918 app.dogs
919 .insert("jobs".to_string(), crate::config::DogTable::from(table));
920 let resolved = normalize(app).expect("a named dog table normalizes");
921 assert!(resolved.config().dogs.contains_key("jobs"));
922 }
923}